feat: files pane like VS Code: tree, tabs, go to file
- 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:
+58
-14
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user