// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror. // // A level-by-level folder/file browser scoped to the path typed so far. It // fires only once the `[[` token's content begins with `/` (targets are // absolute; free-text search lives in the toolbar modal instead). The content // is split into a parent path (up to and including the last `/`) and a partial // segment (the text after it); the parent's children are fetched from the // existing `?tree=1` endpoint and filtered to names containing the partial // (case-insensitive substring). // // Picking a folder inserts `/` and re-opens the popup to drill one level // deeper; picking a file inserts `` and stops. Only the current segment // is replaced, so the trailing `]]` stays put and the cursor parks before it, // leaving room to type a `::display` alias. Exposes window.WikiComplete.source // for main.js to register via CM's autocompletion(). window.WikiComplete = (function () { // treeURL builds the `?tree=1` request URL for an absolute parent path, // percent-encoding each segment. A leading/trailing slash is tolerated; // root resolves to `/?tree=1`. function treeURL(parent) { var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, ''); if (trimmed === '') return '/?tree=1'; var enc = trimmed.split('/').map(encodeURIComponent).join('/'); return '/' + enc + '/?tree=1'; } function fetchTree(parent) { return fetch(treeURL(parent), { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, }).then(function (r) { // A 404 means the parent folder doesn't exist (typo, or a path under // a file) — treat it as "no completions", not an error. if (r.status === 404) return null; if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } // makeApply builds the apply() for a chosen entry. It replaces only the // current segment ([from, to]); the trailing `]]` is untouched, so the // cursor ends up parked before it. Folders append `/` and re-open the popup // to drill into the next level; files terminate. function makeApply(name, kind) { return function (view, completion, from, to) { var isFolder = kind === 'folder'; var insert = isFolder ? name + '/' : name; view.dispatch({ changes: { from: from, to: to, insert: insert }, selection: { anchor: from + insert.length }, scrollIntoView: true, }); if (isFolder) { // Re-open after the transaction so the completion plugin sees // the updated document (the next level's parent path). setTimeout(function () { CM.startCompletion(view); }, 0); } }; } // CM completion source. Activates when `[[` is followed by content that // begins with `/`. The content is split at its last `/` into a parent path // and a partial segment; the parent's children are fetched, filtered to // names containing the partial (case-insensitive substring), and offered // with name-only labels. function source(context) { var match = context.matchBefore(/\[\[[^\]\n]*/); if (!match) return null; var content = context.state.sliceDoc(match.from + 2, context.pos); if (content[0] !== '/') return null; var lastSlash = content.lastIndexOf('/'); var parent = content.slice(0, lastSlash + 1); var partial = content.slice(lastSlash + 1); // Replace only the current segment: from the start of the partial up to // the next `/` or `]` (or end of line). This narrows re-edits inside an // existing `[[…]]` so drilling doesn't duplicate trailing text. var from = match.from + 2 + lastSlash + 1; var line = context.state.doc.lineAt(context.pos); var to = context.pos; while (to < line.to) { var ch = context.state.sliceDoc(to, to + 1); if (ch === '/' || ch === ']') break; to++; } return new Promise(function (resolve) { if (context.aborted) { resolve(null); return; } fetchTree(parent).then(function (resp) { if (context.aborted || !resp) { resolve(null); return; } var needle = partial.toLowerCase(); var options = (resp.entries || []).reduce(function (acc, e) { if (needle && e.name.toLowerCase().indexOf(needle) === -1) { return acc; } acc.push({ label: e.name, type: e.kind === 'folder' ? 'folder' : 'file', apply: makeApply(e.name, e.kind), }); return acc; }, []); // No validFor: the source re-runs on each keystroke, so every // edit (more chars, backspace, or a `/` that drills into the // next folder) re-fetches and re-filters from scratch. resolve({ from: from, to: to, options: options }); }).catch(function () { resolve(null); }); }); } return { source: source }; })();