Compare commits

..
3 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 927920c162 fix: old links never re-create ended sessions
build-deb / Lint, test, package (push) Successful in 1m56s
build-deb / Deploy to CT 126 (push) Successful in 24s
build-deb / Notify on failure (push) Skipped
Inbox entries, the switcher, palette switches, the attention chip and Matrix
links (?arg=NAME&existing=1) attach with --existing; an ended session gets an
explanation and an explicit "Start again". Inbox entries for ended sessions
can't resurrect them; entries expire after 7 days.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:57:52 -04:00
jaredandClaude Opus 5.5 dee1843986 fix: go to file in big non-git folders; linear reply handling
build-deb / Lint, test, package (push) Successful in 3m0s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- find/git producers piped into head tolerate SIGPIPE, so hitting a cap no
  longer fails the command (and lets ttyd truncate its reply).
- fs-find outside git skips build/cache folders and caps at 10000.
- runCommand checks completion on the stream's tail instead of re-decoding
  the whole reply on every frame.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:50:50 -04:00
jaredandClaude Opus 5.5 e345bf4640 feat: files pane like VS Code: tree, tabs, go to file
build-deb / Lint, test, package (push) Successful in 2m28s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Explorer tree with inline expand/collapse and keyboard navigation, git
  colours, change dots, dimmed gitignored entries (fs-list now flags them),
  file-type badges, resizable explorer.
- Editor tabs: preview tab semantics, instant switching with scroll kept,
  in-place reload when Claude changes an open file.
- Go to file: devdesk attach --fs-find (git ls-files or capped find) and a
  fuzzy-ranked palette (CommandPalette gained custom search/async lists).
- Per-session memory of folders and tabs; empty state with recents.

Closes #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:47 -04:00
13 changed files with 1102 additions and 411 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. - **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`. - 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. - **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. - **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). - **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. - **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 dd_linger
return return
;; ;;
--fs-list | --fs-read | --git-status | --git-show | --asset) --fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset)
local action=${1#--} local action=${1#--}
shift shift
"cmd_${action//-/_}" "$@" "cmd_${action//-/_}" "$@"
+42
View File
@@ -1,3 +1,45 @@
devdesk (0.7.3) bookworm; urgency=medium
* Fix (#38): links that mean "go to that session" no longer re-create one
that was ended. The 🔔 inbox, the session switcher, palette switches, the
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
existing-only; an ended one shows "NAME has ended" with an explicit
"Start NAME again". Inbox entries for ended sessions are marked as such,
and entries older than 7 days are dropped.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
devdesk (0.7.2) bookworm; urgency=medium
* Fix: Go to file failed in big non-git folders such as /root/code: the
file walk was cut off at its cap with SIGPIPE, which failed the command
and truncated its reply. Producers piped into a cap now tolerate it (also
for very large folders and git status). Outside git, Go to file skips
build/cache folders and stops at 10,000 files.
* Large side-channel replies no longer cost quadratic time in the browser.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:50:50 -0400
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 devdesk (0.7.0) bookworm; urgency=medium
* Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the * Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the
+41 -10
View File
@@ -7,6 +7,10 @@
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing : "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}" : "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
# Producers piped into `head` get SIGPIPE once the cap is reached; under
# pipefail that would fail the command and cut its reply short, so they are
# wrapped as `{ producer || true; }`.
# Blob replies: a JSON header line, then base64 between markers (no body for # Blob replies: a JSON header line, then base64 between markers (no body for
# binary files the viewer can't show). The client waits for the END marker. # binary files the viewer can't show). The client waits for the END marker.
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
@@ -72,20 +76,47 @@ dd_blob() {
} }
# --fs-list SESSION [REL]: one directory level, folders first, .git skipped. # --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() { cmd_fs_list() {
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi 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") root=$(dd_session_root "$1")
dir=$(dd_fs_path "$root" "$rel") dir=$(dd_fs_path "$root" "$rel")
[ -d "$dir" ] || dd_die "not a folder: $rel" [ -d "$dir" ] || dd_die "not a folder: $rel"
tmp=$(mktemp)
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name. # %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 | { find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } |
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" | head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp"
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" ' if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
(split("\u0000") | map(select(length > 0) | split("\t") | ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))})) as $all fi
| {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max), jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" '
entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' ($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, .git and build/cache folders; capped at
# DEVDESK_FS_FIND_MAX (10000).
cmd_fs_find() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
local root max=${DEVDESK_FS_FIND_MAX:-10000}
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 || true
else
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
-o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \
-o -type f -printf '%P\0' 2>/dev/null) || true
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 # --fs-read SESSION REL
@@ -117,9 +148,9 @@ cmd_git_status() {
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)") branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
{ {
printf 'S\n' printf 'S\n'
"${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null | head -z -n 1000 | tr '\0' '\n' { "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
printf 'N\n' printf 'N\n'
"${g[@]}" diff --numstat HEAD -- . 2>/dev/null | head -n 1000 { "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \ } | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
--rawfile e <(cat -- "$edits" 2>/dev/null || true) ' --rawfile e <(cat -- "$edits" 2>/dev/null || true) '
(split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st (split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
+3 -1
View File
@@ -28,7 +28,9 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null) message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
url="" url=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session" # existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \ body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h, '{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}') text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
+27 -1
View File
@@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
sleep 2.5 sleep 2.5
[ "$(posts)" -eq 1 ] [ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts" run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ] [ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ]
# answered within the delay: nothing is posted # answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook echo '{"hook_event_name":"PostToolUse"}' | hook
@@ -594,3 +594,29 @@ blob_body() {
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
done 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" ]
# Hitting the cap must not fail the command (SIGPIPE in the producer).
for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done
run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha
[ "$status" -eq 0 ]
[ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ]
run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src
[ "$status" -eq 0 ]
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
}
+13 -6
View File
@@ -93,17 +93,24 @@
<section class="lt-frame dd-files" id="files" aria-label="Files" hidden> <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-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-head">
<div class="dd-files-tabs" role="tablist"> <button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<button type="button" role="tab" data-files-tab="files" aria-selected="true">Files</button> <div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<button type="button" role="tab" data-files-tab="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
</div>
<span class="dd-files-actions"> <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-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> <button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
</span> </span>
</div> </div>
<nav class="dd-files-crumbs" aria-label="Path"></nav> <div class="dd-files-main">
<div class="dd-files-body" tabindex="-1"></div> <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> </section>
<div class="dd-backdrop" id="backdrop" hidden></div> <div class="dd-backdrop" id="backdrop" hidden></div>
+9 -1
View File
@@ -152,13 +152,21 @@ async function runCommandNow(args, timeoutMs) {
clearTimeout(timer); clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks))); resolve(new TextDecoder().decode(concat(chunks)));
}; };
const decoder = new TextDecoder();
let head = '';
let tail = '';
socket.onmessage = (event) => { socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data); const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return; if (type !== Server.OUTPUT) return;
chunks.push(payload.slice()); chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-64);
// Hang up as soon as the answer is complete: devdesk waits for that // Hang up as soon as the answer is complete: devdesk waits for that
// before exiting, so ttyd can't drop its last output (devdesk#35). // before exiting, so ttyd can't drop its last output (devdesk#35).
if (isComplete(new TextDecoder().decode(concat(chunks)))) { // Replies are one JSON line (or a devdesk: error), so only a chunk that
// ends a line with "}" is worth a full parse; big replies stay linear.
if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) {
finish(); finish();
socket.close(); socket.close();
} }
+701 -310
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -8,11 +8,12 @@ import { ago } from './sessions.js';
const KEY = 'devdesk.inbox'; const KEY = 'devdesk.inbox';
const MAX = 30; const MAX = 30;
const MAX_AGE = 7 * 86400 * 1000;
function load() { function load() {
try { try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]'); const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list : []; return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
} catch { } catch {
return []; return [];
} }
@@ -45,6 +46,7 @@ export class Inbox {
this.events = load(); this.events = load();
this.waiting = []; this.waiting = [];
this.alerts = []; this.alerts = [];
this.known = null; // session names that exist, once the list has loaded
this.list = panel.querySelector('.lt-notif-panel-list'); this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead()); panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => { window.addEventListener('storage', (event) => {
@@ -78,9 +80,10 @@ export class Inbox {
} }
// Live state: other sessions waiting on you, firing alerts. // Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts }) { setLive({ waiting, alerts, sessions }) {
if (waiting) this.waiting = waiting; if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts; if (alerts) this.alerts = alerts;
if (sessions) this.known = new Set(sessions);
this.render(); this.render();
} }
@@ -91,7 +94,7 @@ export class Inbox {
} }
unread() { unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length; return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
} }
render() { render() {
@@ -155,12 +158,16 @@ export class Inbox {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent')); nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
for (const e of recent.slice(0, 12)) { for (const e of recent.slice(0, 12)) {
const kind = KINDS[e.kind] ?? { tone: 'cyan' }; const kind = KINDS[e.kind] ?? { tone: 'cyan' };
// An entry about a session that has since ended must not bring it back (devdesk#38).
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
nodes.push(item({ nodes.push(item({
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at), dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
onclick: close(() => { onclick: close(() => {
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x)); this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
save(this.events); save(this.events);
if (e.url) window.open(e.url, '_blank', 'noopener'); if (e.url) window.open(e.url, '_blank', 'noopener');
else if (ended) this.hooks.ended?.(e.session);
else if (e.session) this.hooks.openSession(e.session); else if (e.session) this.hooks.openSession(e.session);
}), }),
})); }));
+49 -10
View File
@@ -58,6 +58,8 @@ const ui = {
}; };
const urlSession = sessionFromSearch(window.location.search); const urlSession = sessionFromSearch(window.location.search);
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
const state = { const state = {
session: urlSession, // null = picker session: urlSession, // null = picker
conn: null, conn: null,
@@ -169,7 +171,11 @@ function recordTransitions(sessions) {
} }
function renderPanelsBadge() { function renderPanelsBadge() {
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] }); inbox?.setLive({
waiting: waitingOthers,
alerts: lastAlerts?.alerts ?? [],
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
});
} }
function onSessionData({ fetched = false } = {}) { function onSessionData({ fetched = false } = {}) {
@@ -316,14 +322,19 @@ function updateUrl(session) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session); if (session) url.searchParams.set('arg', session);
else url.searchParams.delete('arg'); else url.searchParams.delete('arg');
url.searchParams.delete('existing');
if (url.href !== window.location.href) window.history.replaceState(null, '', url); if (url.href !== window.location.href) window.history.replaceState(null, '', url);
} }
// ---------------------------------------------------------------- connection // ---------------------------------------------------------------- connection
// reconnect: automatic re-attach after a drop. It must not re-create a session // existing: go to a session that should already exist (reconnects, the
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4). // switcher, inbox entries, notification links). It must not re-create one that
function connect(session, { reconnect = false } = {}) { // was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
// Creating is explicit: a typed name, the New dialog, the picker, recents.
function connect(session, { existing = false, reconnect = false } = {}) {
const existingOnly = Boolean(session) && (existing || reconnect);
state.existingOnly = existingOnly;
clearTimeout(state.retryTimer); clearTimeout(state.retryTimer);
state.conn?.dispose(); state.conn?.dispose();
state.session = session; state.session = session;
@@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) {
term.reset(); term.reset();
const conn = new TtydConnection({ const conn = new TtydConnection({
arg: session && reconnect ? ['--existing', session] : session, arg: existingOnly ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }), size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => { onOpen: () => {
state.openedAt = Date.now(); state.openedAt = Date.now();
@@ -373,6 +384,25 @@ function onClosed(code) {
// few seconds of opening means the session could not start (the reason is // few seconds of opening means the session could not start (the reason is
// printed in the terminal), so don't retry forever. // printed in the terminal), so don't retry forever.
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000; const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
if (failedFast && state.existingOnly) {
// Most likely ended or renamed since the link was made: say so, and only
// start it again when asked.
const name = state.session;
sessionsPanel?.reload().then(() => {
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
if (known) {
scheduleReconnect();
return;
}
setWsState('disconnected', 'ended');
showOverlay({
title: `${name} has ended`,
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
});
});
return;
}
if (failedFast) { if (failedFast) {
showOverlay({ showOverlay({
title: 'Session did not start', title: 'Session did not start',
@@ -515,7 +545,7 @@ function renderSwitcher() {
return el('button', { return el('button', {
type: 'button', role: 'menuitem', type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`, class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)), title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
}, },
el('span', { class: `lt-dot ${status.dot}` }), el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name), el('span', { class: 'dd-switch-name' }, s.name),
@@ -591,7 +621,7 @@ function buildCommands() {
const status = sessionStatus(s); const status = sessionStatus(s);
add({ add({
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir, id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name), icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
}); });
} }
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() }); add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
@@ -615,6 +645,13 @@ function buildCommands() {
if (state.session) { 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: '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: '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({ add({
id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color', id: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
@@ -859,7 +896,8 @@ function wireChrome() {
badge: $('inbox-badge'), badge: $('inbox-badge'),
panel: $('inbox-panel'), panel: $('inbox-panel'),
hooks: { hooks: {
openSession: (name) => connect(name), openSession: (name) => connect(name, { existing: true }),
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
showAlerts: () => { showAlerts: () => {
setSidebar(true); setSidebar(true);
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' }); $('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
@@ -950,7 +988,7 @@ function wireChrome() {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo(); if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000); }, 60 * 1000);
ui.attention.addEventListener('click', () => { ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target); if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
}); });
} }
@@ -1050,7 +1088,8 @@ async function boot() {
wireUploads(); wireUploads();
refreshAlerts(); refreshAlerts();
await sessionsPanel.refresh(); await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data)); if (urlSession) connect(urlSession, { existing: urlExisting });
else connect(defaultSession(sessionsPanel.data), { existing: true });
} }
boot(); boot();
+58 -14
View File
@@ -47,7 +47,7 @@ toast.info = (message, duration) => toast(message, 'info', duration);
// ---------------------------------------------------------------- dialogs // ---------------------------------------------------------------- dialogs
let openDialogs = 0; 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, // 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 // 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` // 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 { 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.getCommands = getCommands;
this.commands = []; this.commands = [];
this.visible = []; this.visible = [];
this.selected = 0; this.selected = 0;
this.opener = null; this.opener = null;
this.input = el('input', { this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: 'Type a command or a session name…', class: 'lt-cmd-input', type: 'text', placeholder: this.options.placeholder,
autocomplete: 'off', spellcheck: 'false', 'aria-label': 'Command', role: 'combobox', autocomplete: 'off', spellcheck: 'false', 'aria-label': this.options.placeholder, role: 'combobox',
'aria-controls': 'dd-cmd-results', 'aria-expanded': 'true', 'aria-controls': `${id}-results`, 'aria-expanded': 'true',
}); });
this.results = el('div', { class: 'lt-cmd-results', id: 'dd-cmd-results', role: 'listbox' }); this.results = el('div', { class: 'lt-cmd-results', id: `${id}-results`, role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: 'lt-cmd-overlay', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette' }, 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: 'lt-cmd-palette' }, 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), el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results, this.results,
el('div', { class: 'lt-cmd-footer' }, el('div', { class: 'lt-cmd-footer' },
@@ -187,13 +191,29 @@ export class CommandPalette {
return this.overlay.classList.contains('is-open'); return this.overlay.classList.contains('is-open');
} }
open(query = '') { async open(query = '') {
this.opener = document.activeElement; this.opener = document.activeElement;
this.commands = this.getCommands();
this.overlay.classList.add('is-open'); this.overlay.classList.add('is-open');
this.input.value = query; this.input.value = query;
this.render();
this.input.focus(); 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 } = {}) { close({ restoreFocus = true } = {}) {
@@ -246,6 +266,15 @@ export class CommandPalette {
} }
render() { 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 words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => { const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase(); 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()}”`)); this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return; 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; let index = 0;
const nodes = []; const nodes = [];
for (const [group, commands] of groups) { 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) { for (const command of commands) {
const i = index; const i = index;
nodes.push(el('div', { 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), onclick: () => this.run(command),
onmousemove: () => { onmousemove: () => {
if (this.selected !== i) { 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; } .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 { .dd-files {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 0 0 var(--dd-files-w, 46%); flex: 0 0 var(--dd-files-w, 52%);
min-width: 0; min-width: 0;
margin: 0; margin: 0;
background: var(--bg-terminal); background: var(--bg-terminal);
--dd-explorer-w: 240px;
} }
.dd-files[hidden] { display: none; } .dd-files[hidden] { display: none; }
body.dd-resizing { cursor: col-resize; user-select: 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 { .dd-files-resizer {
position: absolute; position: absolute;
top: 0; 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; } 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); } #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); } /* head: explorer toggle | tabs | actions */
.dd-files-tabs { display: flex; } .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-tabs button { .dd-files-head .dd-icon-btn { align-self: center; }
padding: 7px 10px 6px; .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: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
background: none; background: none;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.7rem; font-size: 0.64rem;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer; cursor: pointer;
} }
.dd-files-tabs button:hover { color: var(--text-primary); } .dd-explorer-views button:hover { color: var(--text-primary); }
.dd-files-tabs button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); } .dd-explorer-views 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-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-files-actions { display: flex; gap: 2px; padding-bottom: 4px; } .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-tree-row {
.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 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
width: 100%; min-height: 24px;
padding: 4px 10px; padding-right: 8px;
border: 0;
background: none;
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.76rem; font-size: 0.76rem;
text-align: left; white-space: nowrap;
cursor: pointer; cursor: pointer;
user-select: none;
} }
.dd-file:hover:not(:disabled), .dd-tree-row:hover { background: var(--bg-tertiary); }
.dd-file:focus-visible { background: var(--accent-cyan-dim); outline: none; } .dd-tree-row:focus-visible { outline: 1px solid var(--accent-cyan); outline-offset: -1px; }
.dd-file:disabled { opacity: 0.55; cursor: default; } .dd-tree-row.is-active { background: var(--accent-cyan-dim); box-shadow: inset 2px 0 0 var(--accent-cyan); }
.dd-file-icon { flex: 0 0 12px; color: var(--text-dim); text-align: center; } .dd-tree-row.is-ignored { opacity: 0.5; }
.dd-file.is-dir .dd-file-icon, .dd-tree-row.is-note { color: var(--text-muted); font-size: 0.7rem; cursor: default; }
.dd-file.is-dir .dd-file-name { color: var(--accent-cyan); } .dd-tree-row.is-disabled { opacity: 0.5; cursor: default; }
.dd-file.is-up .dd-file-name { color: var(--text-muted); } .dd-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.dd-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dd-tree-row.git-amber .dd-tree-name { color: var(--accent-amber); }
.dd-file-meta { color: var(--text-muted); font-size: 0.66rem; white-space: nowrap; } .dd-tree-row.git-green .dd-tree-name { color: var(--accent-green); }
.dd-file-age { flex: 0 0 2.6em; text-align: right; } .dd-tree-row.git-red .dd-tree-name { color: var(--accent-red); text-decoration: line-through; }
.dd-file-status { flex: 0 0 1.2em; font-weight: 700; text-align: center; } .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-amber { color: var(--accent-amber); }
.dd-file-status.is-green { color: var(--accent-green); } .dd-file-status.is-green { color: var(--accent-green); }
.dd-file-status.is-red { color: var(--accent-red); } .dd-file-status.is-red { color: var(--accent-red); }
.dd-file-status.is-cyan { color: var(--accent-cyan); } .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-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); } .dd-add { color: var(--accent-green); font-size: 0.66rem; }
.dd-del { color: var(--accent-red); } .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 { padding: 14px; color: var(--text-muted); font-size: 0.76rem; line-height: 1.5; }
.dd-files-msg.is-error { color: var(--accent-red); } .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); } /* editor area */
.dd-viewer-meta { flex: 1; min-width: 8em; color: var(--text-muted); font-size: 0.68rem; } .dd-editor-area { position: relative; display: flex; flex: 1; min-width: 0; flex-direction: column; }
.dd-viewer-actions { display: flex; flex-wrap: wrap; gap: 4px; } .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 { .dd-viewer-btn {
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--border-color); 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); color: var(--accent-cyan);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.68rem; font-size: 0.68rem;
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.dd-viewer-btn:hover { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); } .dd-viewer-btn:hover { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.dd-viewer-back { font-size: 0.8rem; } .dd-viewer-content { display: flex; flex: 1; min-height: 0; flex-direction: column; overflow: hidden; }
.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 > .cm-editor { flex: 1; min-height: 0; } .dd-viewer-content > .cm-editor { flex: 1; min-height: 0; }
.dd-viewer-content .cm-editor.cm-focused { outline: none; } .dd-viewer-content .cm-editor.cm-focused { outline: none; }
.dd-viewer-content.is-image { .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-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; } .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) { @media (max-width: 1099px) {
/* Narrow: the pane covers the terminal instead of squeezing it (squeezing /* Narrow window: the pane covers the terminal instead of squeezing it
would resize tmux for every client). */ (squeezing would resize tmux for every client). */
.dd-files { position: absolute; inset: var(--dd-gap); z-index: 18; flex: none; } .dd-files { position: absolute; inset: var(--dd-gap); z-index: 18; flex: none; }
.dd-files-resizer { display: none; } .dd-files-resizer { display: none; }
} }
@media (max-width: 719px) { @media (max-width: 719px) {
.dd-cmd-btn { display: none; } .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; }
} }