From e345bf46401ce51f6ea03489cb9a9188d871d2f5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 8 Oct 2026 01:40:47 -0400 Subject: [PATCH] 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 --- README.md | 2 +- bin/devdesk | 2 +- debian/changelog | 20 + libexec/devdesk-files.sh | 39 +- tests/attach.bats | 20 + web/index.template.html | 19 +- web/src/files.js | 1011 ++++++++++++++++++++++++++------------ web/src/main.js | 7 + web/src/ui.js | 72 ++- web/styles.css | 198 ++++++-- 10 files changed, 999 insertions(+), 391 deletions(-) diff --git a/README.md b/README.md index ea529fb..5c6583a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that** - **Ask Claude** types an investigation prompt into the current session without sending it. - Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. - **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log. -- **Files** (Ctrl+Shift+E): browse the session's folder beside the terminal, read files with syntax highlighting, preview markdown (sanitized, with its relative images) and images, and review uncommitted changes: git status with line counts, a **C** badge on files Claude edited, and a diff against HEAD. **→ Claude** types `@path` into the prompt. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser. +- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser. - **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary. - **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation). - **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer. diff --git a/bin/devdesk b/bin/devdesk index 333ee72..ea0aea5 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -318,7 +318,7 @@ cmd_attach() { dd_linger return ;; - --fs-list | --fs-read | --git-status | --git-show | --asset) + --fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset) local action=${1#--} shift "cmd_${action//-/_}" "$@" diff --git a/debian/changelog b/debian/changelog index bd3164f..6480b1a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +devdesk (0.7.1) bookworm; urgency=medium + + * Files pane reworked like VS Code (#37): + - Explorer tree beside the editor: folders expand in place (also with + the keyboard: arrows, Enter), the open file is highlighted and can be + revealed, git colours on names, change dots on folders, gitignored + entries dimmed, file-type badges. A drag handle sizes the explorer. + - Tabs: a click opens a preview tab (italic) that the next click + replaces; double-click or Enter keeps it. Switching tabs is instant and + keeps the scroll position. Files Claude changes reload in place. + - Go to file (Ctrl+P in the pane, the ⌕ button, the palette): fuzzy + search over git ls-files, or a capped find outside git. + - Explorer and Changes are two views of the left column; diffs open as + tabs. + - Expanded folders, open tabs and the active tab are remembered per + session; an empty editor offers recent files and the change count. + - Narrow panes and phones: the explorer slides over the editor. + + -- Jared Vititoe Thu, 08 Oct 2026 01:40:47 -0400 + devdesk (0.7.0) bookworm; urgency=medium * Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the diff --git a/libexec/devdesk-files.sh b/libexec/devdesk-files.sh index d4bab39..2dc64e0 100644 --- a/libexec/devdesk-files.sh +++ b/libexec/devdesk-files.sh @@ -72,20 +72,45 @@ dd_blob() { } # --fs-list SESSION [REL]: one directory level, folders first, .git skipped. +# In a git repository, entries .gitignore covers are flagged (the UI dims them). cmd_fs_list() { if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi - local root dir rel=${2:-.} + local root dir rel=${2:-.} tmp ignored="" root=$(dd_session_root "$1") dir=$(dd_fs_path "$root" "$rel") [ -d "$dir" ] || dd_die "not a folder: $rel" + tmp=$(mktemp) # %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name. find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null | - head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" | - jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" ' - (split("\u0000") | map(select(length > 0) | split("\t") | - {t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))})) as $all - | {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max), - entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' + head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp" + if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true) + fi + jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" ' + ($ign | split("\n") | map(select(length > 0) | rtrimstr("/")) | INDEX(.)) as $ignored + | (split("\u0000") | map(select(length > 0) | split("\t") | + {t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))} + | if $ignored[.n] then . + {ig: true} else . end)) as $all + | {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max), + entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' <"$tmp" + rm -f "$tmp" +} + +# --fs-find SESSION: every file under the session folder, for "go to file". +# git ls-files in a repository (tracked + untracked, .gitignore honoured), else +# find without node_modules and .git; capped at DEVDESK_FS_FIND_MAX. +cmd_fs_find() { + [ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION" + local root max=${DEVDESK_FS_FIND_MAX:-20000} + root=$(dd_session_root "$1") + if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null + else + (cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv \) -prune \ + -o -type f -printf '%P\0' 2>/dev/null) + fi | head -z -n "$((max + 1))" | + jq -cMRs --argjson max "$max" --arg root "$root" \ + '(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}' } # --fs-read SESSION REL diff --git a/tests/attach.bats b/tests/attach.bats index 6f8ac59..8048483 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -594,3 +594,23 @@ blob_body() { [ "$status" -ne 0 ] done } + +@test "fs-list flags gitignored entries; fs-find lists files for go-to-file (#37)" { + p="$DEVDESK_PROJECTS_ROOT/alpha" + git -C "$p" init -q -b main + mkdir -p "$p/src" "$p/build" "$p/node_modules/x" + printf 'build/\n*.log\n' >"$p/.gitignore" + echo a >"$p/src/a.js" + echo b >"$p/debug.log" + echo c >"$p/node_modules/x/i.js" + run devdesk attach --fs-list alpha + [ "$(jq -r '[.entries[] | select(.ig) | .n] | join(",")' <<<"$output")" = "build,debug.log" ] + run devdesk attach --fs-find alpha + [ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,node_modules/x/i.js,src/a.js" ] + # Outside git: find, without node_modules + rm -rf "$p/.git" + run devdesk attach --fs-find alpha + [ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ] + run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha + [ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ] +} diff --git a/web/index.template.html b/web/index.template.html index a3690cf..c807c37 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -93,17 +93,24 @@ diff --git a/web/src/files.js b/web/src/files.js index 08270a4..b7afeb8 100644 --- a/web/src/files.js +++ b/web/src/files.js @@ -1,14 +1,15 @@ /* global __EDITOR_SHA__ */ -// Files pane (devdesk#19): browse the session's folder, read files with syntax -// highlighting, preview markdown and images, and review what changed (git -// status + diff against HEAD, with a badge on files Claude edited). Read-only -// in v1. Everything goes through `devdesk attach --fs-*` over ttyd. +// Files pane (devdesk#19, #37), laid out like VS Code: an explorer tree (or the +// git changes list) beside an editor area with tabs. Read the session's files +// with syntax highlighting, preview markdown and images, diff against HEAD, +// see what Claude edited, and jump to any file by name. Read-only for now. +// Everything goes through `devdesk attach --fs-*` over ttyd. import { runBlob, runCommand } from './connection.js'; import { parseListOutput } from './protocol.js'; import { el } from './dom.js'; import { ago } from './sessions.js'; -import { toast } from './ui.js'; +import { CommandPalette, toast } from './ui.js'; // ---------------------------------------------------------------- editor bundle @@ -85,7 +86,6 @@ export function loadEditor() { // ---------------------------------------------------------------- helpers const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`); -const join = (dir, name) => (dir ? `${dir}/${name}` : name); const parent = (path) => path.split('/').slice(0, -1).join('/'); const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path); @@ -145,66 +145,171 @@ async function inlineImages(html, session, mdPath) { return doc.body.innerHTML; } +// ---------------------------------------------------------------- prefs + +function readJson(key, fallback) { + try { + const value = JSON.parse(localStorage.getItem(`devdesk.files.${key}`)); + return value ?? fallback; + } catch { + return fallback; + } +} + +function writeJson(key, value) { + try { + localStorage.setItem(`devdesk.files.${key}`, JSON.stringify(value)); + } catch { + // Private mode: the state lasts until the tab closes. + } +} + +// ---------------------------------------------------------------- icons + +// A two-character badge per file type (VS Code's icon idea, in the terminal style). +const ICONS = [ + [/\.(md|markdown|mdx)$/i, 'M↓', 'cyan'], + [/\.(js|mjs|cjs|jsx)$/i, 'JS', 'amber'], + [/\.(ts|tsx|mts)$/i, 'TS', 'blue'], + [/\.(json|jsonc|webmanifest)$/i, '{}', 'amber'], + [/\.(py|pyi)$/i, 'PY', 'blue'], + [/\.(sh|bash|zsh|bats|env)$|^\.env/i, '$_', 'green'], + [/\.(ya?ml)$/i, 'YM', 'purple'], + [/\.(toml|ini|cfg|conf|properties)$/i, '⚙', 'muted'], + [/\.(service|timer|socket|mount)$/i, 'SD', 'muted'], + [/\.(html?|xml|svg|j2|jinja|php)$/i, '<>', 'orange'], + [/\.(css|scss)$/i, '#', 'blue'], + [/\.(png|jpe?g|gif|webp|ico|bmp)$/i, '▣', 'purple'], + [/\.(go)$/i, 'GO', 'cyan'], + [/\.(rs)$/i, 'RS', 'orange'], + [/\.(sql)$/i, 'DB', 'amber'], + [/\.(lock)$|-lock\.json$/i, '≡', 'muted'], + [/^(dockerfile|.*\.dockerfile)$/i, 'DK', 'cyan'], + [/^(makefile|gnumakefile)$/i, 'MK', 'orange'], + [/^(license|copying)(\..*)?$/i, '©', 'muted'], + [/^\.git/i, '±', 'muted'], + [/\.(diff|patch)$/i, '±', 'green'], + [/\.(txt|log)$/i, '≡', 'muted'], +]; + +function iconFor(name) { + for (const [re, text, tone] of ICONS) if (re.test(name)) return [text, tone]; + return ['·', 'muted']; +} + +const base = (path) => path.split('/').pop(); + +// Fuzzy "go to file": every query character in order; consecutive runs, the +// file name, and starts of segments score higher; shorter paths break ties. +export function fuzzyScore(path, query) { + if (!query) return 1; + const p = path.toLowerCase(); + const q = query.toLowerCase().replace(/\s+/g, ''); + const nameStart = p.lastIndexOf('/') + 1; + let score = 0; + let last = -1; + let run = 0; + for (const ch of q) { + const i = p.indexOf(ch, last + 1); + if (i < 0) return null; + run = i === last + 1 ? run + 1 : 0; + score += 1 + run * 3 + (i >= nameStart ? 4 : 0) + (i === 0 || p[i - 1] === '/' || p[i - 1] === '.' || p[i - 1] === '-' || p[i - 1] === '_' ? 3 : 0); + last = i; + } + if (p.slice(nameStart).includes(q)) score += 20; + return score - path.length / 100; +} + // ---------------------------------------------------------------- pane -function readPref(key) { - try { - return localStorage.getItem(`devdesk.files.${key}`); - } catch { - return null; - } -} - -function writePref(key, value) { - try { - localStorage.setItem(`devdesk.files.${key}`, value); - } catch { - // Private mode: the preference lasts until the tab closes. - } -} - export class FilesPane { // hooks: session() -> name, paste(text), onToggle(open), focusTerminal() constructor(root, hooks) { this.root = root; this.hooks = hooks; - this.tab = 'files'; - this.dir = ''; this.session = null; - this.listing = null; + this.listings = new Map(); // dir -> listing (per session; cleared on switch) + this.loadingDirs = new Set(); this.changes = null; - this.current = null; // { path, mode: 'view' | 'diff' | 'preview', sha } - this.editorView = null; - this.seq = 0; - this.wrap = readPref('wrap') === '1'; - this.body = root.querySelector('.dd-files-body'); - this.crumbs = root.querySelector('.dd-files-crumbs'); - this.tabs = [...root.querySelectorAll('[data-files-tab]')]; - this.count = root.querySelector('.dd-files-count'); - for (const button of this.tabs) button.addEventListener('click', () => this.showTab(button.dataset.filesTab)); - root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh({ force: true })); - root.querySelector('.dd-files-close').addEventListener('click', () => this.close()); - } + this.tabs = []; // { path, mode, pinned } + this.active = null; + this.views = new Map(); // tab key -> { node, editor, header, base64, stale } + this.view = 'explorer'; + this.expanded = new Set(); + this.focusIndex = 0; + this.wrap = readJson('wrap', false); + this.fileList = null; // go-to-file cache { at, files } - // Esc: back from a file to the list, then close. main.js routes Esc here - // whenever the pane is open and the terminal doesn't have the focus. - escape() { - if (this.current) this.back(); - else this.close(); + this.explorer = root.querySelector('.dd-explorer-body'); + this.tabStrip = root.querySelector('.dd-tabs'); + this.editorArea = root.querySelector('.dd-editor-area'); + this.count = root.querySelector('.dd-files-count'); + this.viewButtons = [...root.querySelectorAll('[data-files-view]')]; + for (const button of this.viewButtons) button.addEventListener('click', () => this.setView(button.dataset.filesView)); + root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh()); + root.querySelector('.dd-files-close').addEventListener('click', () => this.close()); + root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open()); + root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown())); + this.explorer.addEventListener('keydown', (event) => this.treeKey(event)); + root.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyP') { + event.preventDefault(); + this.quickOpen.open(); + } + }); + this.wireExplorerResize(); + // Narrow pane (phone, or dragged small): the explorer slides over the editor. + new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root); + this.quickOpen = new CommandPalette(() => this.loadFileList(), { + id: 'dd-goto', placeholder: 'Go to file… (type part of a name)', + search: (files, query) => (query + ? files.map((c) => ({ c, s: fuzzyScore(c.id, query) })).filter((x) => x.s !== null).sort((a, b) => b.s - a.s).slice(0, 60).map((x) => x.c) + : [...this.recent().map((p) => files.find((c) => c.id === p)).filter(Boolean), ...files.slice(0, 40)].slice(0, 40)), + }); } get isOpen() { return !this.root.hidden; } - open(tab) { - if (tab) this.tab = tab; + // Drag the explorer's right edge; the width is remembered per browser. + wireExplorerResize() { + const handle = el('div', { class: 'dd-explorer-resizer', role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize the explorer' }); + this.root.querySelector('.dd-explorer').after(handle); + const apply = (px) => { + const width = Math.round(Math.max(160, Math.min(this.root.clientWidth * 0.6, px))); + this.root.style.setProperty('--dd-explorer-w', `${width}px`); + return width; + }; + const saved = readJson('explorerWidth', 0); + if (saved) apply(saved); + handle.addEventListener('pointerdown', (event) => { + event.preventDefault(); + handle.setPointerCapture(event.pointerId); + document.body.classList.add('dd-resizing'); + const left = this.root.querySelector('.dd-explorer').getBoundingClientRect().left; + const move = (e) => apply(e.clientX - left); + const up = () => { + document.body.classList.remove('dd-resizing'); + handle.removeEventListener('pointermove', move); + handle.removeEventListener('pointerup', up); + writeJson('explorerWidth', this.root.querySelector('.dd-explorer').getBoundingClientRect().width); + }; + handle.addEventListener('pointermove', move); + handle.addEventListener('pointerup', up); + }); + } + + // ------------------------------------------------------------ open / close / session + + open(view) { this.root.hidden = false; this.hooks.onToggle(true); this.syncSession(); - this.renderTabs(); - if (!this.current) this.refresh(); - loadEditor().catch(() => {}); // warm up while the list loads + if (view) this.setView(view === 'files' ? 'explorer' : view); + else this.render(); + this.revalidate(); + loadEditor().catch(() => {}); // warm up } close() { @@ -213,340 +318,626 @@ export class FilesPane { this.hooks.focusTerminal(); } - toggle(tab) { - if (this.isOpen && (!tab || tab === this.tab)) this.close(); - else this.open(tab); + toggle(view) { + if (this.isOpen && (!view || (view === 'changes') === (this.view === 'changes'))) this.close(); + else this.open(view); + } + + escape() { + if (this.root.classList.contains('is-narrow') && this.explorerShown() && this.active) this.setExplorer(false); + else this.close(); + } + + stateKey() { + return `state.${this.session}`; + } + + saveState() { + if (!this.session) return; + writeJson(this.stateKey(), { + expanded: [...this.expanded], view: this.view, active: this.active, + tabs: this.tabs.filter((t) => t.pinned || t.key === this.active).map(({ path, mode }) => ({ path, mode })), + }); } - // The pane follows the current session; a different session starts at its root. syncSession() { const session = this.hooks.session(); if (session === this.session) return false; - this.session = session; - this.dir = ''; - this.listing = null; + for (const v of this.views.values()) v.editor?.destroy(); + this.views.clear(); + this.listings.clear(); this.changes = null; - this.destroyEditor(); - this.current = null; + this.fileList = null; + this.session = session; + const saved = session ? readJson(this.stateKey(), {}) : {}; + this.expanded = new Set(saved.expanded ?? []); + this.view = saved.view ?? 'explorer'; + this.tabs = (saved.tabs ?? []).map((t) => ({ ...t, key: `${t.mode}:${t.path}`, pinned: true })); + this.active = this.tabs.some((t) => t.key === saved.active) ? saved.active : (this.tabs[0]?.key ?? null); return true; } sessionChanged() { - if (this.isOpen && this.syncSession()) this.refresh(); - } - - showTab(tab) { - this.tab = tab; - this.destroyEditor(); - this.current = null; - this.renderTabs(); - this.refresh(); - } - - renderTabs() { - for (const button of this.tabs) button.setAttribute('aria-selected', String(button.dataset.filesTab === this.tab)); - } - - // After Claude's turn: refresh what's visible; an open file that changed on - // disk gets a reload notice instead of jumping under you. - async claudeTurnEnded() { - if (!this.isOpen) return; - if (!this.current) { - this.refresh(); - return; - } - this.loadChanges().catch(() => {}); - if (this.current.mode === 'view' || this.current.mode === 'preview') { - try { - const { header } = await runBlob(['--fs-read', this.session, this.current.path]); - if (header.sha && header.sha !== this.current.sha) this.showStale(); - } catch { - // deleted or unreadable: leave it - } + if (this.isOpen && this.syncSession()) { + this.render(); + this.revalidate(); } } - async refresh({ force = false } = {}) { - if (!this.session) { - this.message('Open a session to browse its files.'); - return; - } - if (this.current && !force) return; - if (this.current && force) { - this.openFile(this.current.path, this.current.mode); - return; - } - if (this.tab === 'files') await this.loadDir(this.dir); - else await this.renderChanges(true); - if (this.tab === 'files') this.loadChanges().catch(() => {}); - } + // ------------------------------------------------------------ data - message(text, tone = '') { - this.crumbs.replaceChildren(); - this.body.replaceChildren(el('div', { class: `dd-files-msg${tone ? ` is-${tone}` : ''}` }, text)); - } - - // ------------------------------------------------------------ files tab - - async loadDir(dir) { + async listDir(dir) { + if (this.loadingDirs.has(dir)) return; const session = this.session; - const seq = ++this.seq; // a newer navigation wins over a slower reply - this.renderCrumbs(dir); - if (!this.listing || this.listing.dir !== dir) this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…')); + this.loadingDirs.add(dir); try { const listing = parseListOutput(await runCommand(['--fs-list', session, dir || '.'], 15000)); - if (seq !== this.seq || session !== this.session || this.current || this.tab !== 'files') return; - this.listing = listing; - this.dir = listing.dir; - this.renderDir(); + if (session !== this.session) return; + this.listings.set(dir, listing); } catch (error) { - if (seq !== this.seq) return; + if (session !== this.session) return; if (dir) { + this.expanded.delete(dir); toast.error(`${dir}: ${error.message}`); - this.dir = ''; - this.loadDir(''); } else { - this.message(error.message, 'error'); + this.listings.set('', { error: error.message, entries: [] }); } + } finally { + this.loadingDirs.delete(dir); } + if (session === this.session) this.renderExplorer(); } - renderCrumbs(path, { file = false } = {}) { - const parts = path ? path.split('/') : []; - const nodes = [el('button', { type: 'button', class: 'dd-crumb', title: this.listing?.root, onclick: () => this.goDir('') }, this.session)]; - parts.forEach((part, i) => { - nodes.push(el('span', { class: 'dd-crumb-sep' }, '/')); - const target = parts.slice(0, i + 1).join('/'); - const last = i === parts.length - 1; - nodes.push(last && file - ? el('span', { class: 'dd-crumb is-file' }, part) - : el('button', { type: 'button', class: `dd-crumb${last ? ' is-current' : ''}`, onclick: () => this.goDir(target) }, part)); - }); - this.crumbs.replaceChildren(...nodes); - } - - goDir(dir) { - this.destroyEditor(); - this.current = null; - this.tab = 'files'; + async loadChanges() { + const session = this.session; + try { + const changes = parseListOutput(await runCommand(['--git-status', session], 15000)); + if (session !== this.session) return; + this.changes = changes; + } catch { + return; + } + const n = this.changes.files?.length ?? 0; + this.count.textContent = n ? String(n) : ''; + this.renderExplorer(); this.renderTabs(); - this.loadDir(dir); + if (!this.active) this.renderEditor(); + } + + async loadFileList() { + if (this.fileList && Date.now() - this.fileList.at < 60000 && this.fileList.session === this.session) return this.fileList.items; + const list = parseListOutput(await runCommand(['--fs-find', this.session], 20000)); + const items = list.files.map((path) => { + const name = base(path); + const [icon] = iconFor(name); + return { id: path, label: name, hint: path.includes('/') ? path.slice(0, path.length - name.length - 1) : '', icon, group: 'Files', run: () => this.openFile(path, { pin: true }) }; + }); + this.fileList = { at: Date.now(), session: this.session, items }; + if (list.truncated) toast.info(`Go to file searches the first ${list.files.length} files`); + return items; + } + + // Refresh what's visible: the tree's open folders, git status, the active file. + revalidate() { + if (!this.session) return; + this.listDir(''); + for (const dir of this.expanded) this.listDir(dir); + this.loadChanges(); + } + + refresh() { + this.fileList = null; + this.revalidate(); + if (this.active) this.reloadTab(this.active, { quiet: false }); + } + + async claudeTurnEnded() { + if (!this.isOpen) { + // Next time it opens it revalidates anyway; open tabs re-check on activation. + for (const v of this.views.values()) v.stale = true; + return; + } + this.fileList = null; + this.revalidate(); + for (const [key, v] of this.views) if (key !== this.active) v.stale = true; + if (this.active) this.reloadTab(this.active, { quiet: true }); } changeFor(path) { return this.changes?.files?.find((f) => f.path === path) ?? null; } - renderDir() { - const { entries, truncated } = this.listing; - this.renderCrumbs(this.dir); - const rows = []; - if (this.dir) { - rows.push(el('li', {}, el('button', { type: 'button', class: 'dd-file is-up', onclick: () => this.goDir(parent(this.dir)) }, - el('span', { class: 'dd-file-icon' }, '↰'), el('span', { class: 'dd-file-name' }, '..')))); - } - for (const e of entries) { - const isDir = e.t === 'd' || e.to === 'd'; - const path = join(this.dir, e.n); - const change = isDir ? null : this.changeFor(path); - const dirty = isDir && this.changes?.files?.some((f) => f.path?.startsWith(`${path}/`)); - rows.push(el('li', {}, el('button', { - type: 'button', class: `dd-file${isDir ? ' is-dir' : ''}${e.t === 'l' ? ' is-link' : ''}`, - title: `${path}${e.t === 'l' ? ' (symlink)' : ''}`, - onclick: () => (isDir ? this.goDir(path) : this.openFile(path)), - }, - el('span', { class: 'dd-file-icon', 'aria-hidden': 'true' }, isDir ? '▸' : e.t === 'l' ? '↪' : '·'), - el('span', { class: 'dd-file-name' }, e.n), - change ? el('span', { class: `dd-file-status is-${statusOf(change)[2]}`, title: statusOf(change)[1] }, statusOf(change)[0]) : null, - change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, - dirty ? el('span', { class: 'dd-file-status is-amber', title: 'contains changes' }, '•') : null, - el('span', { class: 'dd-file-meta' }, isDir ? '' : size(e.s)), - el('span', { class: 'dd-file-meta dd-file-age' }, ago(e.m))))); - } - if (!entries.length) rows.push(el('li', { class: 'dd-files-msg' }, 'Empty folder.')); - if (truncated) rows.push(el('li', { class: 'dd-files-msg' }, `Only the first ${entries.length} entries are shown.`)); - this.body.replaceChildren(el('ul', { class: 'dd-file-list' }, ...rows)); + recent() { + return readJson(`recent.${this.session}`, []); } - // ------------------------------------------------------------ changes tab - - async loadChanges() { - const session = this.session; - const changes = parseListOutput(await runCommand(['--git-status', session], 15000)); - if (session !== this.session) return null; - this.changes = changes; - const n = changes.files?.length ?? 0; - this.count.textContent = n ? String(n) : ''; - if (this.tab === 'files' && !this.current && this.listing) this.renderDir(); - return changes; + pushRecent(path) { + writeJson(`recent.${this.session}`, [path, ...this.recent().filter((p) => p !== path)].slice(0, 8)); } - async renderChanges(reload) { - this.crumbs.replaceChildren(el('span', { class: 'dd-crumb is-file' }, this.changes?.git === false ? 'Edited by Claude' : `${this.changes?.branch ?? ''} · uncommitted changes`)); - if (reload || !this.changes) { - this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…')); - try { - await this.loadChanges(); - } catch (error) { - this.message(error.message, 'error'); + // ------------------------------------------------------------ layout + + explorerShown() { + return !this.root.classList.contains('explorer-hidden'); + } + + setExplorer(show) { + this.root.classList.toggle('explorer-hidden', !show); + if (show) this.focusTree(); + } + + setView(view) { + this.view = view; + this.saveState(); + this.setExplorer(true); + this.render(); + if (view === 'changes') this.loadChanges(); + } + + render() { + for (const b of this.viewButtons) b.setAttribute('aria-selected', String(b.dataset.filesView === this.view)); + this.renderExplorer(); + this.renderTabs(); + this.renderEditor(); + } + + // ------------------------------------------------------------ explorer + + // Visible rows: the root listing plus every expanded folder's children. + rows() { + const out = []; + const walk = (dir, depth) => { + const listing = this.listings.get(dir); + if (!listing) { + out.push({ kind: 'loading', depth, dir }); return; } - } - if (this.tab !== 'changes' || this.current) return; - const { files = [], git, branch } = this.changes; - this.crumbs.replaceChildren(el('span', { class: 'dd-crumb is-file' }, git ? `${branch} · ${files.length} changed` : 'Not a git repository · files Claude edited')); - if (!files.length) { - this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files in this folder yet.')); + for (const e of listing.entries) { + const path = dir ? `${dir}/${e.n}` : e.n; + const isDir = e.t === 'd' || e.to === 'd'; + out.push({ kind: isDir ? 'dir' : 'file', depth, path, entry: e }); + if (isDir && this.expanded.has(path)) walk(path, depth + 1); + } + if (listing.truncated) out.push({ kind: 'note', depth, text: `first ${listing.entries.length} entries only` }); + }; + walk('', 0); + return out; + } + + renderExplorer() { + if (!this.session) { + this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Open a session to browse its files.')); return; } + if (this.view === 'changes') { + this.renderChangesList(); + return; + } + const root = this.listings.get(''); + if (root?.error) { + this.explorer.replaceChildren(el('div', { class: 'dd-files-msg is-error' }, root.error)); + return; + } + const activePath = this.tabs.find((t) => t.key === this.active)?.path; + const changed = new Map((this.changes?.files ?? []).filter((f) => f.path).map((f) => [f.path, f])); + this.treeRows = this.rows(); + this.focusIndex = Math.min(this.focusIndex, Math.max(0, this.treeRows.length - 1)); + const nodes = this.treeRows.map((row, i) => { + const indent = { style: `padding-left:${8 + row.depth * 14}px` }; + if (row.kind === 'loading') return el('div', { class: 'dd-tree-row is-note', ...indent }, 'Loading…'); + if (row.kind === 'note') return el('div', { class: 'dd-tree-row is-note', ...indent }, row.text); + const { entry, path } = row; + const isDir = row.kind === 'dir'; + const open = isDir && this.expanded.has(path); + const change = isDir ? null : changed.get(path); + const dirty = isDir && [...changed.keys()].some((p) => p.startsWith(`${path}/`)); + const [icon, tone] = isDir ? [open ? '▾' : '▸', 'dir'] : iconFor(entry.n); + const status = change ? statusOf(change) : null; + return el('div', { + class: `dd-tree-row${isDir ? ' is-dir' : ''}${entry.ig ? ' is-ignored' : ''}${path === activePath ? ' is-active' : ''}${status ? ` git-${status[2]}` : ''}${dirty ? ' git-dirty' : ''}`, + role: 'treeitem', 'aria-expanded': isDir ? String(open) : null, 'aria-level': String(row.depth + 1), + tabindex: i === this.focusIndex ? '0' : '-1', 'data-index': String(i), + title: `${path}${entry.t === 'l' ? ' (symlink)' : ''}${status ? ` · ${status[1]}` : ''}${change?.claude ? ' · edited by Claude' : ''}`, + ...indent, + onclick: () => { + this.focusIndex = i; + if (isDir) this.toggleDir(path); + else this.openFile(path); + }, + ondblclick: () => { + if (!isDir) this.pin(path); + }, + }, + el('span', { class: `dd-ficon tone-${tone}`, 'aria-hidden': 'true' }, icon), + el('span', { class: 'dd-tree-name' }, entry.n), + change?.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, + status ? el('span', { class: `dd-file-status is-${status[2]}` }, status[0]) : null, + dirty ? el('span', { class: 'dd-dirty-dot', title: 'contains changes' }) : null); + }); + if (!nodes.length) nodes.push(el('div', { class: 'dd-files-msg' }, 'Empty folder.')); + const hadFocus = this.explorer.contains(document.activeElement); + this.explorer.setAttribute('role', 'tree'); + this.explorer.replaceChildren(...nodes); + if (hadFocus) this.focusTree(); + } + + toggleDir(path, open = !this.expanded.has(path)) { + if (open) { + this.expanded.add(path); + if (!this.listings.has(path)) this.listDir(path); + else this.listDir(path); // revalidate in the background, show the cached one now + } else { + this.expanded.delete(path); + } + this.saveState(); + this.renderExplorer(); + } + + focusTree() { + const row = this.explorer.querySelector(`[data-index="${this.focusIndex}"]`) ?? this.explorer.querySelector('[data-index]'); + row?.focus(); + } + + // ↑/↓ move, → open folder / step in, ← close folder / step out, Enter/Space open. + treeKey(event) { + if (this.view !== 'explorer' || !this.treeRows?.length) return; + const row = this.treeRows[this.focusIndex]; + const move = (i) => { + this.focusIndex = Math.max(0, Math.min(this.treeRows.length - 1, i)); + for (const node of this.explorer.querySelectorAll('[data-index]')) node.tabIndex = Number(node.dataset.index) === this.focusIndex ? 0 : -1; + this.focusTree(); + }; + switch (event.key) { + case 'ArrowDown': move(this.focusIndex + 1); break; + case 'ArrowUp': move(this.focusIndex - 1); break; + case 'Home': move(0); break; + case 'End': move(this.treeRows.length - 1); break; + case 'ArrowRight': + if (row?.kind === 'dir') { + if (!this.expanded.has(row.path)) this.toggleDir(row.path, true); + else move(this.focusIndex + 1); + } + break; + case 'ArrowLeft': + if (row?.kind === 'dir' && this.expanded.has(row.path)) this.toggleDir(row.path, false); + else if (row?.path?.includes('/')) { + const up = row.path.split('/').slice(0, -1).join('/'); + move(this.treeRows.findIndex((r) => r.path === up)); + } + break; + case 'Enter': + case ' ': + if (row?.kind === 'dir') this.toggleDir(row.path); + else if (row?.kind === 'file') this.openFile(row.path, { pin: event.key === 'Enter' }); + break; + default: + return; + } + event.preventDefault(); + } + + // Expand the folders above a file and scroll it into view. + reveal(path) { + const parts = path.split('/'); + let changed = false; + for (let i = 1; i < parts.length; i += 1) { + const dir = parts.slice(0, i).join('/'); + if (!this.expanded.has(dir)) { + this.expanded.add(dir); + changed = true; + if (!this.listings.has(dir)) this.listDir(dir); + } + } + if (changed) this.saveState(); + if (this.view !== 'explorer') this.setView('explorer'); + else this.renderExplorer(); + this.explorer.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' }); + } + + renderChangesList() { + if (!this.changes) { + this.explorer.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…')); + return; + } + const { files = [], git, branch } = this.changes; + const head = el('div', { class: 'dd-changes-head' }, git ? `${branch} · ${files.length} uncommitted` : 'Not a git repository · files Claude edited'); + if (!files.length) { + this.explorer.replaceChildren(head, el('div', { class: 'dd-files-msg' }, git ? 'No uncommitted changes.' : 'Claude hasn\'t edited files here yet.')); + return; + } + const activePath = this.tabs.find((t) => t.key === this.active)?.path; const rows = files.map((f) => { const [letter, label, tone] = statusOf(f); - const name = f.path ?? f.repoPath; + const path = f.path ?? f.repoPath; + const name = base(path); + const dir = path.slice(0, path.length - name.length).replace(/\/$/, ''); const openable = f.path !== null && f.path !== undefined; - return el('li', {}, el('button', { - type: 'button', class: 'dd-file', disabled: !openable, - title: `${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`, - onclick: () => this.openFile(f.path, git && f.x !== '?' && f.y !== '?' ? 'diff' : 'view'), + const canDiff = git && f.x !== '?' && f.y !== '?'; + return el('div', { + class: `dd-tree-row dd-change-row git-${tone}${path === activePath ? ' is-active' : ''}${openable ? '' : ' is-disabled'}`, + tabindex: '0', role: 'button', + title: `${path} · ${label}${f.from ? ` from ${f.from}` : ''}${f.claude ? ' · edited by Claude' : ''}${openable ? '' : ' (outside this folder)'}`, + onclick: () => openable && this.openFile(f.path, { mode: canDiff ? 'diff' : undefined }), + onkeydown: (event) => { + if (event.key === 'Enter' && openable) this.openFile(f.path, { mode: canDiff ? 'diff' : undefined, pin: true }); + }, }, - el('span', { class: `dd-file-status is-${tone}` }, letter), - el('span', { class: 'dd-file-name' }, name), + el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]), + el('span', { class: 'dd-tree-name' }, name, dir ? el('span', { class: 'dd-change-dir' }, ` ${dir}`) : null), f.claude ? el('span', { class: 'dd-claude-chip', title: 'Claude edited this file' }, 'C') : null, - el('span', { class: 'dd-file-meta' }, - f.add !== null && f.add !== undefined ? el('span', { class: 'dd-add' }, `+${f.add}`) : null, - f.del !== null && f.del !== undefined ? el('span', { class: 'dd-del' }, ` −${f.del}`) : null))); + f.add !== null && f.add !== undefined ? el('span', { class: 'dd-add' }, `+${f.add}`) : null, + f.del !== null && f.del !== undefined ? el('span', { class: 'dd-del' }, `−${f.del}`) : null, + el('span', { class: `dd-file-status is-${tone}` }, letter)); }); - this.body.replaceChildren(el('ul', { class: 'dd-file-list' }, ...rows)); + this.explorer.setAttribute('role', 'list'); + this.explorer.replaceChildren(head, ...rows); } - // ------------------------------------------------------------ viewer + // ------------------------------------------------------------ tabs - destroyEditor() { - this.editorView?.destroy(); - this.editorView = null; - } - - back() { - this.destroyEditor(); - this.current = null; - if (this.tab === 'files') this.loadDir(this.dir); - else this.renderChanges(false); - this.body.focus(); - } - - showStale() { - const bar = this.body.querySelector('.dd-viewer-stale'); - if (bar) bar.hidden = false; - } - - async openFile(path, mode) { - const session = this.session; - this.destroyEditor(); - const change = this.changeFor(path); + // Single click opens a preview tab (italic) that the next click replaces; + // double click, Enter or go-to-file keeps it. + openFile(path, { mode, pin = false } = {}) { const wanted = mode ?? (isMarkdown(path) ? 'preview' : 'view'); - this.current = { path, mode: wanted, sha: null }; - this.seq += 1; - this.renderCrumbs(path, { file: true }); - this.body.replaceChildren(el('div', { class: 'dd-files-msg' }, 'Loading…')); - let blob; - let original = null; - let editor; - try { - const deleted = change && (change.x === 'D' || change.y === 'D'); - [blob, original, editor] = await Promise.all([ - deleted ? Promise.resolve({ header: { path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', session, path]), - wanted === 'diff' ? runBlob(['--git-show', session, path]) : Promise.resolve(null), - loadEditor().catch((error) => ({ error })), - ]); - } catch (error) { - if (session === this.session) this.viewerMessage(path, error.message, 'error'); + const key = `${wanted}:${path}`; + let tab = this.tabs.find((t) => t.key === key) ?? this.tabs.find((t) => t.path === path && !mode); + if (!tab) { + const preview = this.tabs.findIndex((t) => !t.pinned); + tab = { key, path, mode: wanted, pinned: pin }; + if (preview >= 0 && !pin) { + this.dropView(this.tabs[preview].key); + this.tabs[preview] = tab; + } else { + this.tabs.push(tab); + while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false }); + } + } else if (pin) { + tab.pinned = true; + } + this.pushRecent(path); + this.activate(tab.key); + if (this.root.classList.contains('is-narrow')) this.setExplorer(false); + } + + pin(path) { + const tab = this.tabs.find((t) => t.path === path); + if (tab) { + tab.pinned = true; + this.saveState(); + this.renderTabs(); + } + } + + activate(key) { + this.active = key; + this.saveState(); + this.renderTabs(); + this.renderEditor(); + this.renderExplorer(); + } + + closeTab(key, { render = true } = {}) { + const i = this.tabs.findIndex((t) => t.key === key); + if (i < 0) return; + this.tabs.splice(i, 1); + this.dropView(key); + if (this.active === key) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.key ?? null; + this.saveState(); + if (render) { + this.renderTabs(); + this.renderEditor(); + this.renderExplorer(); + } + } + + dropView(key) { + this.views.get(key)?.editor?.destroy(); + this.views.get(key)?.node.remove(); + this.views.delete(key); + } + + renderTabs() { + const nodes = this.tabs.map((tab) => { + const name = base(tab.path); + const change = this.changeFor(tab.path); + const status = change ? statusOf(change) : null; + return el('div', { + class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`, + role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1', + title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`, + onclick: () => this.activate(tab.key), + ondblclick: () => this.pin(tab.path), + onauxclick: (event) => { + if (event.button === 1) this.closeTab(tab.key); + }, + onkeydown: (event) => { + if (event.key === 'Enter') this.activate(tab.key); + }, + }, + el('span', { class: `dd-ficon tone-${iconFor(name)[1]}`, 'aria-hidden': 'true' }, iconFor(name)[0]), + el('span', { class: 'dd-tab-name' }, name), + tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null, + el('button', { + type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close', + onclick: (event) => { + event.stopPropagation(); + this.closeTab(tab.key); + }, + }, '×')); + }); + this.tabStrip.replaceChildren(...nodes); + this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + // ------------------------------------------------------------ editor + + renderEditor() { + for (const [key, v] of this.views) v.node.hidden = key !== this.active; + let empty = this.editorArea.querySelector('.dd-editor-empty'); + if (!this.active) { + if (!empty) { + empty = el('div', { class: 'dd-editor-empty' }); + this.editorArea.append(empty); + } + empty.hidden = false; + this.renderEmpty(empty); return; } - if (session !== this.session || this.current?.path !== path) return; - const { header, base64 } = blob; - this.current.sha = header.sha ?? null; + if (empty) empty.hidden = true; + const view = this.views.get(this.active); + if (!view) this.loadTab(this.active); + else if (view.stale) this.reloadTab(this.active, { quiet: true }); + } + renderEmpty(node) { + const recent = this.recent(); + const n = this.changes?.files?.length ?? 0; + node.replaceChildren(...[ + el('div', { class: 'dd-empty-title' }, this.session ? `No file open in ${this.session}` : 'No session'), + el('div', { class: 'dd-empty-actions' }, + el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.quickOpen.open() }, 'Go to file… Ctrl+P'), + n ? el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.setView('changes') }, `Review ${n} change${n === 1 ? '' : 's'}`) : null), + recent.length ? el('div', { class: 'dd-empty-recent' }, + el('div', { class: 'dd-empty-label' }, 'Recent'), + ...recent.map((path) => el('button', { type: 'button', class: 'dd-recent', title: path, onclick: () => this.openFile(path, { pin: true }) }, + el('span', { class: `dd-ficon tone-${iconFor(base(path))[1]}` }, iconFor(base(path))[0]), + el('span', { class: 'dd-recent-name' }, base(path)), + el('span', { class: 'dd-change-dir' }, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')))) : null, + el('div', { class: 'dd-empty-hint' }, 'Click a file for a preview tab, double-click to keep it open. Esc returns to the terminal.'), + ].filter(Boolean)); + } + + async fetchTab(tab) { + const change = this.changeFor(tab.path); + const deleted = change && (change.x === 'D' || change.y === 'D'); + const [blob, original] = await Promise.all([ + deleted ? Promise.resolve({ header: { path: tab.path, size: 0, utf8: true, body: true }, base64: '' }) : runBlob(['--fs-read', this.session, tab.path]), + tab.mode === 'diff' ? runBlob(['--git-show', this.session, tab.path]) : Promise.resolve(null), + ]); + return { blob, original }; + } + + async loadTab(key) { + const tab = this.tabs.find((t) => t.key === key); + if (!tab) return; + const node = el('div', { class: 'dd-tab-view' }, el('div', { class: 'dd-files-msg' }, 'Loading…')); + this.editorArea.append(node); + const view = { node, editor: null, header: null, stale: false }; + this.views.set(key, view); + node.hidden = key !== this.active; + const session = this.session; + try { + const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]); + if (session !== this.session || this.views.get(key) !== view) return; + this.fill(view, tab, blob, original, editor); + } catch (error) { + if (this.views.get(key) !== view) return; + node.replaceChildren(this.bar(tab, null), el('div', { class: 'dd-files-msg is-error' }, error.message)); + } + } + + // Claude changed files: re-read; if the content differs, swap it in place + // and keep the scroll position (read-only, so nothing of yours is lost). + async reloadTab(key, { quiet }) { + const tab = this.tabs.find((t) => t.key === key); + const view = this.views.get(key); + if (!tab || !view) { + if (tab) this.loadTab(key); + return; + } + view.stale = false; + try { + const session = this.session; + const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]); + if (session !== this.session || this.views.get(key) !== view) return; + if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return; + const scroller = view.node.querySelector('.cm-scroller'); + const top = scroller?.scrollTop ?? 0; + view.editor?.destroy(); + this.fill(view, tab, blob, original, editor); + const next = view.node.querySelector('.cm-scroller'); + if (next) next.scrollTop = top; + if (quiet && view.header?.sha !== blob.header.sha) view.node.querySelector('.dd-viewer-meta')?.classList.add('is-flash'); + } catch { + // gone or unreadable: keep what's shown + } + } + + bar(tab, header) { + const change = this.changeFor(tab.path); + const meta = header ? [ + size(header.size ?? 0), + header.mtime ? `changed ${ago(header.mtime)}` : '', + header.utf8 === false ? 'Latin-1' : '', + tab.mode === 'diff' ? 'diff against HEAD' : '', + change ? statusOf(change)[1] : '', + change?.claude ? 'edited by Claude' : '', + ].filter(Boolean).join(' · ') : ''; + const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label); + const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true }); + const actions = []; + if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview'))); + if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) { + actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff'))); + } + if (tab.mode !== 'preview') { + actions.push(button(this.wrap ? 'No wrap' : 'Wrap', () => { + this.wrap = !this.wrap; + writeJson('wrap', this.wrap); + for (const key of [...this.views.keys()]) this.dropView(key); + this.renderEditor(); + }, { title: 'Wrap long lines' })); + } + actions.push(button('Reveal', () => this.reveal(tab.path), { title: 'Show in the explorer' })); + actions.push(button('Copy', () => navigator.clipboard?.writeText(tab.path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused')), { title: 'Copy the path' })); + actions.push(button('@Claude', () => { + this.hooks.paste(`@${tab.path} `); + toast.info(`Mentioned @${tab.path} in the prompt`); + }, { title: 'Type @path into the current prompt so Claude reads the file' })); + return el('div', { class: 'dd-viewer-bar' }, + el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') }, + el('span', { class: 'dd-viewer-path' }, tab.path), + meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null), + el('span', { class: 'dd-viewer-actions' }, ...actions)); + } + + fill(view, tab, blob, original, editor) { + const { header, base64 } = blob; + view.header = header; const text = (b64, utf8 = true) => new TextDecoder(utf8 ? 'utf-8' : 'latin1').decode(decodeBase64(b64 || '')); const content = el('div', { class: 'dd-viewer-content' }); - const actions = []; - const action = (label, run, extra = {}) => actions.push(el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label)); - - if (wanted !== 'diff' && isMarkdown(path)) { - action(wanted === 'preview' ? 'Source' : 'Preview', () => this.openFile(path, wanted === 'preview' ? 'view' : 'preview')); - } - if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) { - action(wanted === 'diff' ? 'File' : 'Diff', () => this.openFile(path, wanted === 'diff' ? (isMarkdown(path) ? 'preview' : 'view') : 'diff')); - } - if (wanted === 'view' || wanted === 'diff') { - action(this.wrap ? 'No wrap' : 'Wrap', () => { - this.wrap = !this.wrap; - writePref('wrap', this.wrap ? '1' : '0'); - this.openFile(path, wanted); - }, { title: 'Wrap long lines' }); - } - action('Copy path', () => navigator.clipboard?.writeText(path).then(() => toast.success('Path copied'), () => toast.error('Clipboard refused'))); - action('→ Claude', () => { - this.hooks.paste(`@${path} `); - toast.info(`Mentioned @${path} in the prompt`); - }, { title: 'Type @path into the current prompt so Claude reads the file' }); - if (header.tooLarge) { content.append(el('div', { class: 'dd-files-msg' }, `This file is ${size(header.size)}; the viewer opens files up to ${size(header.max)}.`)); } else if (header.mime?.startsWith('image/') && base64) { content.classList.add('is-image'); - content.append(el('img', { src: `data:${header.mime};base64,${base64}`, alt: path })); + content.append(el('img', { src: `data:${header.mime};base64,${base64}`, alt: tab.path })); } else if (header.binary || !header.body) { content.append(el('div', { class: 'dd-files-msg' }, `Binary file (${size(header.size)}).`)); } else if (editor?.error) { content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8))); - toast.warning(`Plain view: ${editor.error.message}`); - } else if (wanted === 'preview') { + } else if (tab.mode === 'preview') { content.classList.add('is-preview'); - content.append(el('iframe', { - class: 'dd-md-frame', title: `Preview of ${path}`, + const frame = el('iframe', { + class: 'dd-md-frame', title: `Preview of ${tab.path}`, sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))), - })); - const frame = content.lastChild; - inlineImages(editor.markdown(text(base64)), session, path).then((html) => { + }); + content.append(frame); + inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => { if (frame.isConnected) frame.srcdoc = previewDocument(html); }); - } else if (wanted === 'diff') { - this.editorView = editor.diff(content, { original: text(original?.base64), modified: text(base64), path, wrap: this.wrap }); + } else if (tab.mode === 'diff') { + view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap }); } else { - this.editorView = editor.viewer(content, { doc: text(base64, header.utf8), path, wrap: this.wrap }); + view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap }); } - - const meta = [ - header.size !== undefined && !header.tooLarge ? size(header.size) : '', - header.mtime ? `changed ${ago(header.mtime)}` : '', - header.utf8 === false ? 'not UTF-8 (shown as Latin-1)' : '', - wanted === 'diff' ? 'diff against HEAD' : '', - change ? statusOf(change)[1] : '', - change?.claude ? 'edited by Claude' : '', - ].filter(Boolean).join(' · '); - const stale = el('div', { class: 'dd-viewer-stale', hidden: true }, - 'This file changed on disk. ', el('button', { type: 'button', class: 'dd-viewer-btn', onclick: () => this.openFile(path, wanted) }, 'Reload')); - this.body.replaceChildren( - el('div', { class: 'dd-viewer-bar' }, - el('button', { type: 'button', class: 'dd-viewer-btn dd-viewer-back', title: 'Back (Esc)', onclick: () => this.back() }, '←'), - el('span', { class: 'dd-viewer-meta' }, meta), - el('span', { class: 'dd-viewer-actions' }, ...actions)), - stale, - content); - // Keyboard straight into the file: arrows scroll, Ctrl+F searches, Esc goes back. - if (this.editorView) this.editorView.view.focus(); - else this.body.focus(); + view.node.replaceChildren(this.bar(tab, header), content); } - viewerMessage(path, text, tone) { - this.body.replaceChildren( - el('div', { class: 'dd-viewer-bar' }, - el('button', { type: 'button', class: 'dd-viewer-btn dd-viewer-back', title: 'Back (Esc)', onclick: () => this.back() }, '←'), - el('span', { class: 'dd-viewer-meta' }, path)), - el('div', { class: `dd-files-msg is-${tone}` }, text)); - } - - // Theme switch: the editor follows CSS variables; the preview iframe needs a re-render. + // Theme switch: the editor follows CSS variables; previews need a re-render. themeChanged() { - if (this.current?.mode === 'preview') this.openFile(this.current.path, 'preview'); + for (const tab of this.tabs) { + if (tab.mode === 'preview') this.dropView(tab.key); + } + if (this.isOpen) this.renderEditor(); } } diff --git a/web/src/main.js b/web/src/main.js index 253df9e..ee6a426 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -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', diff --git a/web/src/ui.js b/web/src/ui.js index 3fbb9c7..8826fee 100644 --- a/web/src/ui.js +++ b/web/src/ui.js @@ -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) { diff --git a/web/styles.css b/web/styles.css index 2209da4..2bf3868 100644 --- a/web/styles.css +++ b/web/styles.css @@ -700,19 +700,21 @@ html[data-theme="light"] .lt-notif-dot { box-shadow: none; } .dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; } } -/* ---- files pane (devdesk#19) -------------------------------------------------------- */ +/* ---- files pane (devdesk#19, #37): explorer + tabs + editor ---------------------- */ .dd-files { position: relative; display: flex; flex-direction: column; - flex: 0 0 var(--dd-files-w, 46%); + flex: 0 0 var(--dd-files-w, 52%); min-width: 0; margin: 0; background: var(--bg-terminal); + --dd-explorer-w: 240px; } .dd-files[hidden] { display: none; } body.dd-resizing { cursor: col-resize; user-select: none; } -body.dd-resizing .dd-terminal { pointer-events: none; } +body.dd-resizing .dd-terminal, +body.dd-resizing .dd-md-frame { pointer-events: none; } .dd-files-resizer { position: absolute; top: 0; @@ -727,72 +729,137 @@ body.dd-resizing .dd-terminal { pointer-events: none; } body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transparent 40%, var(--accent-cyan) 40%, var(--accent-cyan) 60%, transparent 60%); outline: none; } #files-toggle[aria-expanded="true"] { color: var(--accent-orange); border-color: var(--accent-orange-border); } -.dd-files-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; padding: 2px 6px 0 6px; border-bottom: 1px solid var(--border-dim); } -.dd-files-tabs { display: flex; } -.dd-files-tabs button { - padding: 7px 10px 6px; +/* head: explorer toggle | tabs | actions */ +.dd-files-head { display: flex; align-items: stretch; gap: 2px; min-height: 34px; padding: 0 4px; border-bottom: 1px solid var(--border-dim); background: var(--bg-secondary); } +.dd-files-head .dd-icon-btn { align-self: center; } +.dd-files-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; } +.dd-tabs { display: flex; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: thin; } +.dd-tab { + display: flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 0 6px 0 10px; + border-right: 1px solid var(--border-dim); + border-top: 2px solid transparent; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.74rem; + white-space: nowrap; + cursor: pointer; + user-select: none; +} +.dd-tab:hover { color: var(--text-primary); background: var(--bg-tertiary); } +.dd-tab.is-active { color: var(--text-primary); background: var(--bg-terminal); border-top-color: var(--accent-orange); } +.dd-tab.is-preview .dd-tab-name { font-style: italic; } +.dd-tab-name { overflow: hidden; text-overflow: ellipsis; } +.dd-tab-tag { padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-muted); font-size: 0.6rem; } +.dd-tab-close { width: 18px; height: 18px; padding: 0; border: 0; background: none; color: inherit; opacity: 0; font-size: 0.9rem; line-height: 1; cursor: pointer; } +.dd-tab:hover .dd-tab-close, +.dd-tab.is-active .dd-tab-close, +.dd-tab-close:focus-visible { opacity: 0.7; } +.dd-tab-close:hover { opacity: 1; color: var(--accent-red); } +.dd-tab.git-amber .dd-tab-name { color: var(--accent-amber); } +.dd-tab.git-green .dd-tab-name { color: var(--accent-green); } +.dd-tab.git-red .dd-tab-name { color: var(--accent-red); text-decoration: line-through; } + +/* main: explorer column | editor */ +.dd-files-main { position: relative; display: flex; flex: 1; min-height: 0; } +.dd-explorer { display: flex; flex-direction: column; flex: 0 0 var(--dd-explorer-w); min-width: 0; border-right: 1px solid var(--border-dim); background: var(--bg-secondary); } +.dd-files.explorer-hidden .dd-explorer, +.dd-files.explorer-hidden .dd-explorer-resizer, +.dd-files.is-narrow .dd-explorer-resizer { display: none; } +.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; } +.dd-explorer-resizer:hover { background: var(--accent-cyan); } +.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); } +.dd-explorer-views button { + flex: 1; + padding: 6px 4px 5px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); - font-size: 0.7rem; + font-size: 0.64rem; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; } -.dd-files-tabs button:hover { color: var(--text-primary); } -.dd-files-tabs button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); } -.dd-files-count:not(:empty) { margin-left: 4px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.62rem; } -.dd-files-actions { display: flex; gap: 2px; padding-bottom: 4px; } +.dd-explorer-views button:hover { color: var(--text-primary); } +.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); } +.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; } +.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; } -.dd-files-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 1px; min-height: 28px; padding: 4px 10px; border-bottom: 1px solid var(--border-dim); font-family: var(--font-mono); font-size: 0.72rem; } -.dd-crumb { padding: 1px 3px; border: 0; background: none; color: var(--accent-cyan); font: inherit; cursor: pointer; } -.dd-crumb:hover { text-decoration: underline; } -.dd-crumb.is-current, -.dd-crumb.is-file { color: var(--text-primary); text-decoration: none; cursor: default; } -.dd-crumb-sep { color: var(--text-dim); } - -.dd-files-body { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: auto; outline: none; } -.dd-file-list { margin: 0; padding: 4px 0; list-style: none; } -.dd-file { +.dd-tree-row { display: flex; align-items: center; - gap: 8px; - width: 100%; - padding: 4px 10px; - border: 0; - background: none; + gap: 6px; + min-height: 24px; + padding-right: 8px; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.76rem; - text-align: left; + white-space: nowrap; cursor: pointer; + user-select: none; } -.dd-file:hover:not(:disabled), -.dd-file:focus-visible { background: var(--accent-cyan-dim); outline: none; } -.dd-file:disabled { opacity: 0.55; cursor: default; } -.dd-file-icon { flex: 0 0 12px; color: var(--text-dim); text-align: center; } -.dd-file.is-dir .dd-file-icon, -.dd-file.is-dir .dd-file-name { color: var(--accent-cyan); } -.dd-file.is-up .dd-file-name { color: var(--text-muted); } -.dd-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.dd-file-meta { color: var(--text-muted); font-size: 0.66rem; white-space: nowrap; } -.dd-file-age { flex: 0 0 2.6em; text-align: right; } -.dd-file-status { flex: 0 0 1.2em; font-weight: 700; text-align: center; } +.dd-tree-row:hover { background: var(--bg-tertiary); } +.dd-tree-row:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: -1px; } +.dd-tree-row.is-active { background: var(--accent-cyan-dim); box-shadow: inset 2px 0 0 var(--accent-cyan); } +.dd-tree-row.is-ignored { opacity: 0.5; } +.dd-tree-row.is-note { color: var(--text-muted); font-size: 0.7rem; cursor: default; } +.dd-tree-row.is-disabled { opacity: 0.5; cursor: default; } +.dd-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.dd-tree-row.git-amber .dd-tree-name { color: var(--accent-amber); } +.dd-tree-row.git-green .dd-tree-name { color: var(--accent-green); } +.dd-tree-row.git-red .dd-tree-name { color: var(--accent-red); text-decoration: line-through; } +.dd-tree-row.git-cyan .dd-tree-name { color: var(--accent-cyan); } +.dd-tree-row.git-dirty > .dd-tree-name { color: var(--accent-amber); } +.dd-dirty-dot { flex: 0 0 6px; height: 6px; border-radius: 50%; background: var(--accent-amber); } +.dd-change-dir { color: var(--text-muted); font-size: 0.66rem; } +.dd-changes-head { padding: 6px 10px; color: var(--text-muted); font-size: 0.68rem; border-bottom: 1px solid var(--border-dim); } + +/* file-type badges */ +.dd-ficon { + flex: 0 0 18px; + font-family: var(--font-mono); + font-size: 0.56rem; + font-weight: 700; + letter-spacing: -0.04em; + text-align: center; + line-height: 1; +} +.dd-ficon.tone-dir { color: var(--text-muted); font-size: 0.7rem; } +.dd-ficon.tone-cyan { color: var(--accent-cyan); } +.dd-ficon.tone-amber { color: var(--accent-amber); } +.dd-ficon.tone-blue { color: #5b9cff; } +.dd-ficon.tone-green { color: var(--accent-green); } +.dd-ficon.tone-purple { color: var(--accent-purple); } +.dd-ficon.tone-orange { color: var(--accent-orange); } +.dd-ficon.tone-muted { color: var(--text-muted); } +html[data-theme="light"] .dd-ficon.tone-blue { color: #1f4fbf; } + +.dd-file-status { flex: 0 0 1.1em; font-weight: 700; text-align: center; font-size: 0.7rem; } .dd-file-status.is-amber { color: var(--accent-amber); } .dd-file-status.is-green { color: var(--accent-green); } .dd-file-status.is-red { color: var(--accent-red); } .dd-file-status.is-cyan { color: var(--accent-cyan); } -.dd-claude-chip { padding: 0 4px; border: 1px solid var(--accent-orange-border); color: var(--accent-orange); font-size: 0.6rem; font-weight: 700; } -.dd-add { color: var(--accent-green); } -.dd-del { color: var(--accent-red); } +.dd-claude-chip { padding: 0 4px; border: 1px solid var(--accent-orange-border); color: var(--accent-orange); font-size: 0.58rem; font-weight: 700; } +.dd-add { color: var(--accent-green); font-size: 0.66rem; } +.dd-del { color: var(--accent-red); font-size: 0.66rem; } .dd-files-msg { padding: 14px; color: var(--text-muted); font-size: 0.76rem; line-height: 1.5; } .dd-files-msg.is-error { color: var(--accent-red); } -.dd-viewer-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 4px 8px; border-bottom: 1px solid var(--border-dim); } -.dd-viewer-meta { flex: 1; min-width: 8em; color: var(--text-muted); font-size: 0.68rem; } -.dd-viewer-actions { display: flex; flex-wrap: wrap; gap: 4px; } +/* editor area */ +.dd-editor-area { position: relative; display: flex; flex: 1; min-width: 0; flex-direction: column; } +.dd-tab-view { display: flex; flex: 1; min-height: 0; flex-direction: column; } +.dd-tab-view[hidden] { display: none; } +.dd-viewer-bar { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 3px 8px; border-bottom: 1px solid var(--border-dim); } +.dd-viewer-where { flex: 1; min-width: 0; overflow: hidden; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; } +.dd-viewer-path { color: var(--text-secondary); } +.dd-viewer-meta { color: var(--text-muted); font-size: 0.66rem; transition: color 0.6s; } +.dd-viewer-meta.is-flash { color: var(--accent-amber); } +.dd-viewer-actions { display: flex; flex: 0 0 auto; gap: 4px; } .dd-viewer-btn { padding: 2px 8px; border: 1px solid var(--border-color); @@ -800,13 +867,11 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar color: var(--accent-cyan); font-family: var(--font-mono); font-size: 0.68rem; + white-space: nowrap; cursor: pointer; } .dd-viewer-btn:hover { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); } -.dd-viewer-back { font-size: 0.8rem; } -.dd-viewer-stale { padding: 4px 10px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.72rem; } -.dd-viewer-stale[hidden] { display: none; } -.dd-viewer-content { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; } +.dd-viewer-content { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; } .dd-viewer-content > .cm-editor { flex: 1; min-height: 0; } .dd-viewer-content .cm-editor.cm-focused { outline: none; } .dd-viewer-content.is-image { @@ -820,13 +885,42 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar .dd-md-frame { flex: 1; width: 100%; border: 0; background: var(--bg-terminal); } .dd-plain { flex: 1; margin: 0; padding: 10px; overflow: auto; font-size: 0.76rem; white-space: pre; } +/* empty editor */ +.dd-editor-empty { display: flex; flex: 1; flex-direction: column; gap: 14px; padding: 28px 24px; overflow: auto; } +.dd-editor-empty[hidden] { display: none; } +.dd-empty-title { color: var(--text-secondary); font-size: 0.82rem; } +.dd-empty-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.dd-empty-actions .dd-viewer-btn { padding: 5px 12px; font-size: 0.72rem; } +.dd-empty-label { margin-bottom: 4px; color: var(--accent-orange); font-size: 0.62rem; letter-spacing: 0.12em; text-transform: uppercase; } +.dd-recent { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 4px; border: 0; background: none; color: var(--accent-cyan); font-family: var(--font-mono); font-size: 0.76rem; text-align: left; cursor: pointer; } +.dd-recent:hover { background: var(--accent-cyan-dim); } +.dd-recent-name { white-space: nowrap; } +.dd-empty-hint { color: var(--text-muted); font-size: 0.68rem; } + +/* Narrow pane (phone, or dragged small): the explorer slides over the editor + and closes when a file opens; ☰ brings it back. */ +.dd-files.is-narrow .dd-explorer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 4; + width: min(300px, 85%); + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.45); +} +html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24px rgba(50, 80, 130, 0.25); } + @media (max-width: 1099px) { - /* Narrow: the pane covers the terminal instead of squeezing it (squeezing - would resize tmux for every client). */ + /* Narrow window: the pane covers the terminal instead of squeezing it + (squeezing would resize tmux for every client). */ .dd-files { position: absolute; inset: var(--dd-gap); z-index: 18; flex: none; } .dd-files-resizer { display: none; } } @media (max-width: 719px) { .dd-cmd-btn { display: none; } - .dd-file-age { display: none; } + .dd-tab { max-width: 160px; } +} +@media (pointer: coarse) { + .dd-tree-row { min-height: 34px; } + .dd-tab-close { opacity: 0.7; width: 28px; height: 28px; } }