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:
2026-10-08 01:40:47 -04:00
co-authored by Claude Opus 5.5
parent 73f8525db2
commit 5d7fe83c83
10 changed files with 999 additions and 391 deletions
+1 -1
View File
@@ -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/<session>.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.
+1 -1
View File
@@ -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//-/_}" "$@"
+20
View File
@@ -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 <jjvititoe1@gmail.com> 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
+32 -7
View File
@@ -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
+20
View File
@@ -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')" ]
}
+13 -6
View File
@@ -93,17 +93,24 @@
<section class="lt-frame dd-files" id="files" aria-label="Files" hidden>
<div class="dd-files-resizer" id="files-resizer" role="separator" aria-orientation="vertical" aria-label="Resize the files pane" tabindex="0"></div>
<div class="dd-files-head">
<div class="dd-files-tabs" role="tablist">
<button type="button" role="tab" data-files-tab="files" aria-selected="true">Files</button>
<button type="button" role="tab" data-files-tab="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
</div>
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<span class="dd-files-actions">
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
</span>
</div>
<nav class="dd-files-crumbs" aria-label="Path"></nav>
<div class="dd-files-body" tabindex="-1"></div>
<div class="dd-files-main">
<div class="dd-explorer">
<div class="dd-explorer-views" role="tablist">
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files"></div>
</div>
<div class="dd-editor-area"></div>
</div>
</section>
<div class="dd-backdrop" id="backdrop" hidden></div>
+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) {
+146 -52
View File
@@ -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; }
}