feat: files pane like VS Code: tree, tabs, go to file
build-deb / Lint, test, package (push) Successful in 2m28s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped

- Explorer tree with inline expand/collapse and keyboard navigation, git
  colours, change dots, dimmed gitignored entries (fs-list now flags them),
  file-type badges, resizable explorer.
- Editor tabs: preview tab semantics, instant switching with scroll kept,
  in-place reload when Claude changes an open file.
- Go to file: devdesk attach --fs-find (git ls-files or capped find) and a
  fuzzy-ranked palette (CommandPalette gained custom search/async lists).
- Per-session memory of folders and tabs; empty state with recents.

Closes #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:40:47 -04:00
co-authored by Claude Opus 5.5
parent 73f8525db2
commit e345bf4640
10 changed files with 999 additions and 391 deletions
+701 -310
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -615,6 +615,13 @@ function buildCommands() {
if (state.session) {
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
add({
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
run: () => {
files.open();
files.quickOpen.open();
},
});
}
add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
+58 -14
View File
@@ -47,7 +47,7 @@ toast.info = (message, duration) => toast(message, 'info', duration);
// ---------------------------------------------------------------- dialogs
let openDialogs = 0;
export const dialogOpen = () => openDialogs > 0 || document.getElementById('lt-cmd-overlay')?.classList.contains('is-open');
export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt-cmd-overlay.is-open');
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
@@ -153,22 +153,26 @@ function pushRecent(id) {
}
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
// is called on every open, so the list reflects the current sessions and state.
// (may be async) is called on every open, so the list reflects the current state.
// Options: id (DOM prefix), placeholder, search(commands, query) -> ranked list
// (replaces the default word filter, recents and grouping; used by Go to file).
export class CommandPalette {
constructor(getCommands) {
constructor(getCommands, options = {}) {
this.options = { id: 'lt-cmd', placeholder: 'Type a command or a session name…', ...options };
const id = this.options.id;
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…',
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox',
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true',
class: 'lt-cmd-input', type: 'text', placeholder: this.options.placeholder,
autocomplete: 'off', spellcheck: 'false', 'aria-label': this.options.placeholder, role: 'combobox',
'aria-controls': `${id}-results`, 'aria-expanded': 'true',
});
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' },
el('div', { class: 'lt-cmd-palette', id: 'lt-cmd-palette' },
this.results = el('div', { class: 'lt-cmd-results', id: `${id}-results`, role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: `${id}-overlay`, role: 'dialog', 'aria-modal': 'true', 'aria-label': this.options.placeholder },
el('div', { class: 'lt-cmd-palette', id: `${id}-palette` },
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results,
el('div', { class: 'lt-cmd-footer' },
@@ -187,13 +191,29 @@ export class CommandPalette {
return this.overlay.classList.contains('is-open');
}
open(query = '') {
async open(query = '') {
this.opener = document.activeElement;
this.commands = this.getCommands();
this.overlay.classList.add('is-open');
this.input.value = query;
this.render();
this.input.focus();
const pending = this.getCommands();
if (pending instanceof Promise) {
this.commands = [];
this.loading = true;
this.render();
try {
this.commands = await pending;
} catch (error) {
this.commands = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, error.message));
return;
} finally {
this.loading = false;
}
} else {
this.commands = pending;
}
if (this.isOpen) this.render();
}
close({ restoreFocus = true } = {}) {
@@ -246,6 +266,15 @@ export class CommandPalette {
}
render() {
if (this.loading) {
this.visible = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, 'Loading…'));
return;
}
if (this.options.search) {
this.renderList(this.options.search(this.commands, this.input.value.trim()));
return;
}
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
@@ -266,14 +295,29 @@ export class CommandPalette {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return;
}
this.renderGroups(groups);
}
// A ranked list without group headers (custom search).
renderList(list) {
this.visible = list;
this.selected = 0;
if (!list.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, this.input.value.trim() ? `Nothing matches “${this.input.value.trim()}”` : 'Nothing here'));
return;
}
this.renderGroups(new Map([['', list]]));
}
renderGroups(groups) {
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
if (group) nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
for (const command of commands) {
const i = index;
nodes.push(el('div', {
class: 'lt-cmd-item', id: `dd-cmd-${i}`, role: 'option',
class: 'lt-cmd-item', id: `${this.options.id}-item-${i}`, role: 'option',
onclick: () => this.run(command),
onmousemove: () => {
if (this.selected !== i) {