334 lines
25 KiB
TypeScript
334 lines
25 KiB
TypeScript
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 { 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><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/ **/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 directory’s .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 preset’s 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 project’s 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);
|
||
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; }
|
||
|
||
function showSetup() {
|
||
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 project’s .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 = async () => {
|
||
showSetup(); source.value = project.root; await inspectSource();
|
||
if (inspection && !inspection.warning && source.value === project.root) $<HTMLFormElement>('setup-form').requestSubmit();
|
||
};
|
||
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();
|