Files
cex/frontend/main.ts
2026-09-07 15:52:46 -04:00

393 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke, isTauri } from '@tauri-apps/api/core';
import { open, confirm } from '@tauri-apps/plugin-dialog';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution';
import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding';
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import './style.css';
import { installSidebarResize } from './resize';
import { installTheme } from './theme';
import { installNavigation } from './navigation';
import { installGraph } from './graph';
import { installVim } from './vim';
import { installExplanations } from './explanations';
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
type Settings = { graph?: { depth: number; excluded_namespaces: string[] }; version: number; build_directory: string | null; last_file: string | null; search_exclusions: string[]; clangd_path: string };
type Workspace = { root: string; settings: Settings };
type Inspection = { root: string; settings: Settings | null; warning: string | null; can_reset: boolean };
type Entry = { path: string; name: string; directory: boolean };
type Hit = { path: string; line: number; column: number; end_column: number; preview: string };
type SearchResults = { hits: Hit[]; truncated: boolean; cancelled: boolean; skipped_files: number };
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<header><details id="file-menu"><summary>File</summary><div class="file-dropdown"><button id="file-open">Open Project…</button><details id="file-recents"><summary>Open Recent</summary><div id="file-recent-list"><span class="hint">Loading…</span></div></details><button id="file-welcome">Return to Welcome</button></div></details><div class="brand">cex<span>CODE EXPLORER</span></div><div id="project-name">No project open</div><select id="theme-select" aria-label="Color theme" title="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button id="toggle-sidebar" class="icon-button" aria-label="Toggle right sidebar" title="Toggle right sidebar" aria-expanded="false" aria-controls="explanation-pane" disabled><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><path d="M12.5 4v12"/></svg></button><button id="workspace-button">Open project…</button></header>
<main class="landing">
<section id="landing" aria-label="Open Recent"><div class="landing-intro"><div class="welcome-mark">{ cex }</div><h1>Open Recent</h1><p>Pick up where you left off, or explore a new project.</p><button id="recent-open" class="primary">Open a project</button></div><div class="recent-heading"><h2>Recent projects</h2><button id="refresh-recents" aria-label="Refresh recent projects">Refresh</button></div><p id="recent-status" class="hint" role="status">Loading recent projects…</p><div id="recent-projects"></div><small class="hint">History is stored in ~/.cex. Project settings stay with each project.</small></section>
<aside><nav><button id="files-tab" class="active">Files</button><button id="search-tab">Search</button><button id="navigation-tab">Navigation</button></nav>
<section id="files-panel"><div class="panel-heading">PROJECT FILES<button id="refresh" title="Refresh file tree" aria-label="Refresh file tree">↻</button></div><div id="tree"><p class="hint">Open a directory to explore its source.</p></div></section>
<section id="search-panel" hidden><label for="query">Search project text</label><input id="query" type="search" placeholder="Literal, case-sensitive search" autocomplete="off"><label for="exclusions" class="exclusions-label">Exclude paths (one glob per line)</label><textarea id="exclusions" rows="2" placeholder="docs/&#10;**/generated/**"></textarea><button id="apply-exclusions">Apply exclusions</button><p id="exclusion-status" class="hint" role="status"></p><div class="search-actions"><small>Respects .gitignore</small><button id="cancel-search" hidden>Cancel</button></div><p id="search-summary" class="hint">Results appear as you type.</p><div id="results"></div></section>
<section id="navigation-panel" hidden><div class="panel-heading">CLANGD<button id="restart-analysis" title="Restart clangd and reload compilation commands">Restart analysis</button></div><p id="analysis-detail" class="hint">Open a project with a build directory to enable navigation.</p><p id="diagnostic-count" class="hint"></p><h3 id="navigation-title">Code navigation</h3><p id="navigation-summary" class="hint">Select a symbol, then choose Definition, References, or Callers.</p><div id="navigation-results"></div><p id="navigation-evidence" class="hint"></p></section>
</aside>
<section class="code-pane"><div id="file-bar"><span id="file-path">Welcome to CEX</span><span class="badge">READ ONLY</span><button id="find-file" disabled>Find in file</button></div><div id="navigation-bar"><button id="nav-back" disabled title="Navigate back (Alt+Left)">← Back</button><button id="nav-definition" disabled title="Go to definition (F12 or Ctrl/Cmd+click)">Definition</button><button id="nav-references" disabled title="Find references (Shift+F12)">References</button><button id="nav-callers" disabled>Callers</button><button id="nav-hover" disabled>Type info</button><button id="nav-graph" disabled>Graph</button><button id="nav-explain" disabled>Explain…</button><button id="analysis-state" title="Show analysis status">Navigation unavailable</button></div><div id="editor"></div><div id="welcome"><div class="welcome-mark">{ cex }</div><h1>Explore the code.</h1><p>Browse C and C++ source, fold sections,<br>and find text across your project.</p><button id="welcome-open" class="primary">Open a project</button><small>Select a Meson or CMake build to enable definitions, references, and callers.</small></div></section>
</main>
<footer><span id="status" role="status">Ready</span><span id="cursor">C / C++ · Read only</span></footer>
<dialog id="setup"><form id="setup-form"><h2 id="setup-title">Open project</h2><p class="hint">Choose the source directory. CEX stores its settings and caches in that directorys .cex folder.</p>
<label for="source">Source directory</label><div class="field-row"><input id="source" placeholder="/path/to/project" required><button id="browse-source" type="button">Browse…</button></div>
<p id="saved-state" class="hint" role="status"></p>
<label for="build">Meson or CMake build directory <span class="optional">optional for browsing</span></label><div class="field-row"><input id="build" placeholder="/path/to/project/build"><button id="browse-build" type="button">Browse…</button></div>
<p class="hint">Select an existing configured build with compile_commands.json. For CMake, enable CMAKE_EXPORT_COMPILE_COMMANDS with a Ninja or Makefile generator; for presets, choose the presets binary directory. CEX does not configure or build your project.</p>
<label for="clangd-path">clangd executable</label><input id="clangd-path" placeholder="clangd" value="clangd"><p class="hint">Executable name on PATH or an absolute path. Changing it restarts analysis.</p>
<label class="reset-label"><input id="reset" type="checkbox">Force reset — discard this projects CEX settings and caches</label>
<p id="setup-error" class="error" role="alert"></p><div class="dialog-actions"><button id="setup-cancel" type="button">Cancel</button><button id="setup-submit" class="primary" type="submit">Open project</button></div>
</form></dialog>`;
const editor = monaco.editor.create($('editor'), {
contextmenu: true,
theme: 'vs-dark', readOnly: true, domReadOnly: true, automaticLayout: true,
fontSize: 14, fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
lineHeight: 22, minimap: { enabled: false }, scrollBeyondLastLine: false,
folding: true, foldingStrategy: 'auto', showFoldingControls: 'always',
renderLineHighlight: 'all', padding: { top: 14 }, wordWrap: 'off',
model: null,
});
installTheme();
let workspace: Workspace | null = null;
let epoch = 0;
let fileRequest = 0;
let searchRequest = 0;
let treeRequest = 0;
let searchTimer: ReturnType<typeof setTimeout>;
let inspecting = 0;
let inspectedRoot = '';
let inspection: Inspection | null = null;
let currentPath = '';
const viewStates = new Map<string, monaco.editor.ICodeEditorViewState>();
let rememberQueue: Promise<unknown> = Promise.resolve();
const setup = $<HTMLDialogElement>('setup');
const source = $<HTMLInputElement>('source');
const build = $<HTMLInputElement>('build');
const reset = $<HTMLInputElement>('reset');
const query = $<HTMLInputElement>('query');
const exclusions = $<HTMLTextAreaElement>('exclusions');
const clangdPath = $<HTMLInputElement>('clangd-path');
const navigation = installNavigation(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, showPanel: () => switchPanel('navigation'), open: openFile });
const explanations = installExplanations(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const graph = installGraph(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const vim = installVim(editor, navigation);
installSidebarResize(editor);
function status(message: string, error = false) { $('status').textContent = message; $('status').classList.toggle('error', error); }
function errorText(error: unknown) { return error instanceof Error ? error.message : String(error); }
function button(text: string, className = '') { const el = document.createElement('button'); el.textContent = text; el.className = className; return el; }
let closingWorkspace = false;
function showSetup() {
if (closingWorkspace) return;
source.value = workspace?.root ?? '';
build.value = workspace?.settings.build_directory ?? '';
clangdPath.value = workspace?.settings.clangd_path ?? 'clangd';
reset.checked = false; reset.disabled = true;
inspectedRoot = ''; inspection = null;
$('saved-state').textContent = ''; $('setup-error').textContent = '';
$('setup-title').textContent = workspace ? 'Workspace settings' : 'Open project';
setup.showModal();
if (source.value) void inspectSource();
}
async function inspectSource() {
const request = ++inspecting;
const root = source.value.trim();
inspectedRoot = ''; inspection = null; reset.checked = false; reset.disabled = true;
if (!root) { $('saved-state').textContent = ''; return; }
$('saved-state').textContent = 'Checking for saved workspace…';
try {
const result = await invoke<Inspection>('inspect_workspace', { root });
if (request !== inspecting || source.value.trim() !== root) return;
inspection = result; inspectedRoot = root;
build.value = result.settings?.build_directory ?? '';
clangdPath.value = result.settings?.clangd_path ?? 'clangd';
reset.disabled = !result.can_reset;
$('saved-state').textContent = result.warning ?? (result.settings ? 'Saved workspace found. Build selection and last file will be restored.' : 'New workspace. A .cex folder will be created on opening.');
} catch (error) { if (request === inspecting) $('saved-state').textContent = errorText(error); }
}
source.addEventListener('input', () => { ++inspecting; inspectedRoot = ''; inspection = null; reset.checked = false; reset.disabled = true; build.value = ''; clangdPath.value = 'clangd'; $('saved-state').textContent = ''; });
source.addEventListener('change', () => void inspectSource());
$('browse-source').onclick = async () => {
try { const path = await open({ directory: true, multiple: false, title: 'Select source directory' }); if (typeof path === 'string') { source.value = path; await inspectSource(); } }
catch (error) { $('setup-error').textContent = errorText(error); }
};
$('browse-build').onclick = async () => {
try { const path = await open({ directory: true, multiple: false, title: 'Select configured Meson or CMake build directory' }); if (typeof path === 'string') build.value = path; }
catch (error) { $('setup-error').textContent = errorText(error); }
};
$('workspace-button').onclick = showSetup;
$('welcome-open').onclick = showSetup;
$('setup-cancel').onclick = () => setup.close();
$('setup-form').onsubmit = async (event) => {
event.preventDefault();
const controls = [...setup.querySelectorAll<HTMLInputElement | HTMLButtonElement>('input, button')];
controls.forEach(c => c.disabled = true);
$('setup-error').textContent = '';
try {
if (inspectedRoot !== source.value.trim()) await inspectSource();
if (!inspection) throw new Error('Choose an accessible source directory first.');
if (inspection.warning && !reset.checked) throw new Error(inspection.warning);
if (reset.checked && !await confirm('Delete CEX settings and caches in this projects .cex folder and start fresh? Source and build files will be preserved.', { title: 'Force reset CEX workspace', kind: 'warning' })) return;
++fileRequest;
await rememberQueue;
const next = await invoke<Workspace>('open_workspace', { root: source.value.trim(), build: build.value.trim() || null, reset: reset.checked, clangdPath: clangdPath.value.trim() || "clangd" });
workspace = next; $('landing').hidden = true; document.querySelector('main')!.classList.remove('landing'); ++epoch; ++fileRequest; ++searchRequest;
navigation.reset();
vim.reset();
explanations.reset();
graph.reset();
exclusions.value = (next.settings.search_exclusions ?? []).join('\n');
$('exclusion-status').textContent = '';
void navigation.start();
clearTimeout(searchTimer); viewStates.clear(); currentPath = '';
const model = editor.getModel(); editor.setModel(null); model?.dispose();
$('welcome').hidden = false; $('file-path').textContent = 'Choose a source file';
$<HTMLButtonElement>('find-file').disabled = true;
query.value = ''; $('results').replaceChildren(); $('search-summary').textContent = 'Results appear as you type.';
$('cancel-search').hidden = true;
$('project-name').textContent = next.root.split('/').pop() || next.root;
$('project-name').title = next.root;
$('workspace-button').textContent = 'Workspace…';
setup.close(); switchPanel('files');
status(next.settings.build_directory ? `Build: ${next.settings.build_directory}` : 'Browsing without a build directory');
await refreshTree();
if (next.settings.last_file) await openFile(next.settings.last_file);
try { await invoke('remember_recent', { root: next.root }); }
catch (error) { status(`Project opened, but recent history could not be saved: ${errorText(error)}`, true); }
} catch (error) { $('setup-error').textContent = errorText(error); status(errorText(error), true); }
finally { controls.forEach(c => c.disabled = false); reset.disabled = !inspection?.can_reset; }
};
function switchPanel(panel: 'files' | 'search' | 'navigation') {
for (const name of ['files', 'search', 'navigation']) {
$(name + '-panel').hidden = name !== panel;
$(name + '-tab').classList.toggle('active', name === panel);
}
if (panel === 'search') query.focus();
}
$('files-tab').onclick = () => switchPanel('files');
$('search-tab').onclick = () => switchPanel('search');
$('navigation-tab').onclick = () => switchPanel('navigation');
async function directoryNodes(path: string, level: number, activeEpoch: number): Promise<DocumentFragment> {
const entries = await invoke<Entry[]>('list_directory', { path });
const fragment = document.createDocumentFragment();
if (epoch !== activeEpoch) return fragment;
for (const entry of entries) {
const row = button(`${entry.directory ? '▸' : '·'} ${entry.name}`, 'tree-row');
row.style.paddingLeft = `${12 + level * 15}px`; row.title = entry.path;
if (entry.directory) {
row.setAttribute('aria-expanded', 'false');
const children = document.createElement('div'); children.hidden = true;
let loaded = false;
row.onclick = async () => {
if (epoch !== activeEpoch) return;
if (!loaded) {
row.disabled = true;
try { children.replaceChildren(await directoryNodes(entry.path, level + 1, activeEpoch)); loaded = true; }
catch (error) { status(errorText(error), true); return; }
finally { row.disabled = false; }
}
children.hidden = !children.hidden;
row.textContent = `${children.hidden ? '▸' : '▾'} ${entry.name}`;
row.setAttribute('aria-expanded', String(!children.hidden));
};
fragment.append(row, children);
} else {
row.dataset.path = entry.path;
row.classList.toggle('selected', entry.path === currentPath);
row.onclick = () => { if (epoch === activeEpoch) void openFile(entry.path); };
fragment.append(row);
}
}
return fragment;
}
async function refreshTree() {
if (!workspace) return;
const request = ++treeRequest, activeEpoch = epoch;
$('tree').textContent = 'Loading files…';
try {
const nodes = await directoryNodes('', 0, activeEpoch);
if (request === treeRequest && activeEpoch === epoch) {
$('tree').replaceChildren(nodes);
if (!$('tree').childElementCount) $('tree').textContent = 'This directory is empty.';
}
} catch (error) { if (request === treeRequest) { $('tree').textContent = 'Unable to list files.'; status(errorText(error), true); } }
}
$('refresh').onclick = () => void refreshTree();
async function openFile(path: string, hit?: Omit<Hit, 'preview'>, semantic = false) {
if (!workspace) return;
const request = ++fileRequest, activeEpoch = epoch, root = workspace.root;
status(`Loading ${path}`);
try {
const doc = await invoke<{ path: string; content: string }>('read_file', { path });
if (request !== fileRequest || activeEpoch !== epoch) return;
const previous = editor.getModel(), view = editor.saveViewState();
if (view && currentPath) viewStates.set(currentPath, view);
const language = semantic || /\.(c|h|cc|hh|cpp|hpp|cxx|hxx|ipp|tpp|inl|ixx|cppm)$/i.test(path) ? 'cpp' : 'plaintext';
const model = monaco.editor.createModel(doc.content, language);
editor.setModel(model); previous?.dispose(); currentPath = path;
if (viewStates.has(path)) editor.restoreViewState(viewStates.get(path)!);
if (hit) { editor.setSelection({ startLineNumber: hit.line, endLineNumber: hit.line, startColumn: hit.column, endColumn: hit.end_column }); editor.revealLineInCenter(hit.line); }
$('welcome').hidden = true; $('file-path').textContent = path; $('file-path').title = path;
$<HTMLButtonElement>('find-file').disabled = false;
document.querySelectorAll<HTMLElement>('[data-path]').forEach(row => row.classList.toggle('selected', row.dataset.path === path));
status(`${model.getLineCount().toLocaleString()} lines · ${language === 'cpp' ? 'C / C++' : 'Plain text'} · Read only`);
editor.focus();
navigation.fileOpened();
explanations.fileOpened();
graph.fileOpened();
if (path.startsWith('/') || (workspace.settings.build_directory && `${root}/${path}`.startsWith(workspace.settings.build_directory + '/'))) return;
workspace.settings.last_file = path;
rememberQueue = rememberQueue.then(() => invoke('remember_file', { path, root })).catch(error => { if (epoch === activeEpoch) status(`File opened, but settings could not be saved: ${errorText(error)}`, true); });
} catch (error) { if (request === fileRequest && activeEpoch === epoch) status(errorText(error), true); }
}
editor.onDidChangeCursorPosition(event => { $('cursor').textContent = `Ln ${event.position.lineNumber}, Col ${event.position.column} · Read only`; });
$('find-file').onclick = () => { editor.focus(); void editor.getAction('actions.find')?.run(); };
function cancelSearch() {
++searchRequest; clearTimeout(searchTimer);
void invoke('cancel_search').catch(error => status(errorText(error), true));
$('cancel-search').hidden = true;
}
$('cancel-search').onclick = () => { cancelSearch(); $('search-summary').textContent = 'Search cancelled.'; };
$('apply-exclusions').onclick = async () => {
if (!workspace) { $('exclusion-status').textContent = 'Open a project first.'; return; }
const patterns = exclusions.value.split('\n').map(s => s.trim()).filter(Boolean);
const root = workspace.root, activeEpoch = epoch;
$<HTMLButtonElement>('apply-exclusions').disabled = true;
cancelSearch();
try {
await invoke('save_search_exclusions', { root, exclusions: patterns });
if (epoch !== activeEpoch) return;
workspace.settings.search_exclusions = patterns;
$('exclusion-status').textContent = 'Exclusions saved for this project.';
query.dispatchEvent(new Event('input'));
} catch (error) { if (epoch === activeEpoch) $('exclusion-status').textContent = errorText(error); }
finally { $<HTMLButtonElement>('apply-exclusions').disabled = false; }
};
query.oninput = () => {
cancelSearch(); $('results').replaceChildren();
if (!workspace) { $('search-summary').textContent = 'Open a project first.'; return; }
if (!query.value) { $('search-summary').textContent = 'Results appear as you type.'; return; }
const request = searchRequest, activeEpoch = epoch, term = query.value;
$('search-summary').textContent = 'Searching…'; $('cancel-search').hidden = false;
searchTimer = setTimeout(async () => {
try {
const result = await invoke<SearchResults>('search', { query: term });
if (request !== searchRequest || epoch !== activeEpoch) return;
const fragment = document.createDocumentFragment();
for (const hit of result.hits) {
const row = button('', 'result');
const location = document.createElement('strong'); location.textContent = `${hit.path}:${hit.line}`;
const preview = document.createElement('span'); preview.textContent = hit.preview;
row.append(location, preview); row.title = `${hit.path}:${hit.line}:${hit.column}`;
row.onclick = () => { if (epoch === activeEpoch) void openFile(hit.path, hit); };
fragment.append(row);
}
$('results').replaceChildren(fragment);
$('search-summary').textContent = `${result.hits.length}${result.truncated ? '+' : ''} matches${result.truncated ? ' — limit reached; narrow your search' : ''}${result.cancelled ? ' (cancelled)' : ''}${result.skipped_files ? ` · ${result.skipped_files} unreadable, binary, oversized or unsupported files skipped` : ''}`;
} catch (error) { if (request === searchRequest) $('search-summary').textContent = errorText(error); }
finally { if (request === searchRequest) $('cancel-search').hidden = true; }
}, 250);
};
document.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'f') { event.preventDefault(); switchPanel('search'); }
});
if (!isTauri()) {
status('Browser preview only. Run npm run desktop to access local projects.', true);
$('workspace-button').onclick = $('welcome-open').onclick = () => status('Folder access requires the desktop app: npm run desktop', true);
}
async function loadRecents() {
if (!isTauri()) { $('recent-status').textContent = 'Open the desktop app to see recent projects.'; return; }
$('recent-status').textContent = 'Loading recent projects…';
try {
const projects = await invoke<{ root: string; last_opened: number }[]>('recent_projects');
$('recent-projects').replaceChildren();
for (const project of projects) {
const row = document.createElement('div'); row.className = 'recent-row';
const open = button('', 'recent-project');
const name = document.createElement('strong'); name.textContent = project.root.split('/').pop() || project.root;
const path = document.createElement('span'); path.textContent = project.root;
const date = document.createElement('small'); date.textContent = `Last opened ${new Date(project.last_opened * 1000).toLocaleString()}`;
open.append(name, path, date); open.title = project.root;
open.onclick = () => void openRecent(project.root);
const remove = button('×', 'recent-remove'); remove.setAttribute('aria-label', `Remove ${name.textContent} from recent projects`);
remove.title = 'Remove from history; project files are preserved';
remove.onclick = async () => {
remove.disabled = true;
try { await invoke('remove_recent', { root: project.root }); await loadRecents(); }
catch (error) { $('recent-status').textContent = errorText(error); remove.disabled = false; }
};
row.append(open, remove); $('recent-projects').append(row);
}
$('recent-status').textContent = projects.length ? 'Select a project to restore its saved build and last viewed file.' : 'No recent projects yet. Open a project to get started.';
} catch (error) { $('recent-status').textContent = `Could not load recent projects: ${errorText(error)}`; }
}
$('recent-open').onclick = () => $('workspace-button').click();
$('refresh-recents').onclick = () => void loadRecents();
void loadRecents();
function closeFileMenu() { $<HTMLDetailsElement>('file-menu').open = false; }
async function openRecent(root: string) {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); source.value = root; await inspectSource();
if (setup.open && inspection && !inspection.warning && source.value === root) $<HTMLFormElement>('setup-form').requestSubmit();
}
$('file-open').onclick = () => {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); ++inspecting;
source.value = ''; build.value = ''; clangdPath.value = 'clangd'; inspectedRoot = ''; inspection = null;
reset.checked = false; reset.disabled = true; $('saved-state').textContent = ''; $('setup-title').textContent = 'Open project'; source.focus();
};
$('file-recents').addEventListener('toggle', async () => {
if (!$<HTMLDetailsElement>('file-recents').open) return;
$('file-recent-list').textContent = 'Loading…';
try {
const projects = await invoke<{root:string;last_opened:number}[]>('recent_projects');
$('file-recent-list').replaceChildren(...projects.map(project => {
const item = button('', 'file-recent');
const name = document.createElement('strong'); name.textContent = project.root.split('/').pop() || project.root;
const path = document.createElement('small'); path.textContent = project.root;
item.append(name,path); item.title = project.root; item.onclick = () => void openRecent(project.root); return item;
}));
if (!projects.length) $('file-recent-list').textContent = 'No recent projects yet.';
} catch(error) { $('file-recent-list').textContent = errorText(error); }
});
$('file-menu').addEventListener('toggle', () => {
if ($<HTMLDetailsElement>('file-menu').open) {
$<HTMLButtonElement>('file-welcome').disabled = !workspace || closingWorkspace;
$<HTMLDetailsElement>('file-recents').open = false;
}
});
document.addEventListener('pointerdown', event => { if (!$('file-menu').contains(event.target as Node)) closeFileMenu(); });
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && $<HTMLDetailsElement>('file-menu').open) { closeFileMenu(); $('file-menu').querySelector('summary')!.focus(); }
});
$('file-welcome').onclick = async () => {
if (!workspace || closingWorkspace) return;
const root = workspace.root;
closingWorkspace = true; closeFileMenu(); ++epoch; ++fileRequest; ++searchRequest;
clearTimeout(searchTimer); graph.reset(); explanations.reset();
status('Closing project…');
try {
await rememberQueue;
await invoke('close_workspace', {root});
workspace = null; currentPath = ''; viewStates.clear(); navigation.reset(); vim.reset();
const model = editor.getModel(); editor.setModel(null); model?.dispose();
graph.reset(); $<HTMLButtonElement>('toggle-sidebar').disabled = true;
$('landing').hidden = false; document.querySelector('main')!.classList.add('landing');
$('project-name').textContent = 'No project open'; $('project-name').title = '';
$('workspace-button').textContent = 'Open project…'; $('file-path').textContent = 'Welcome to CEX';
$('cursor').textContent = ''; $('tree').replaceChildren(); $('results').replaceChildren(); query.value = '';
status('Ready'); await loadRecents(); $('recent-open').focus();
} catch(error) { status(`Could not close project: ${errorText(error)}`, true); }
finally { closingWorkspace = false; }
};