feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset (libexec/devdesk-files.sh), confined to the session folder. - The state hook records files Claude edits (Edit/Write/MultiEdit/ NotebookEdit) for the Changes tab's "C" badge; rename/end carry it. - Files pane: folders, CodeMirror read-only view, sanitized markdown preview in a sandboxed iframe with inlined relative images, images, Changes tab with line counts and diff against HEAD, "→ Claude" @path, resizable split. - The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched over ttyd on first use, verified against the page's SHA-256 and cached in IndexedDB. - Fix: the state hook's field split collapsed empty fields. Closes #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+27
-1
@@ -2,6 +2,7 @@
|
||||
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
|
||||
import { build, transform } from 'esbuild';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -33,8 +34,29 @@ async function fontFaces(weights) {
|
||||
return faces.join('\n');
|
||||
}
|
||||
|
||||
// The file viewer's editor (devdesk#19): its own IIFE, served over the ttyd
|
||||
// channel by `devdesk attach --asset editor.js` and cached by the browser. The
|
||||
// page carries its SHA-256, which is both the integrity check and the cache key.
|
||||
async function editorBundle() {
|
||||
const out = await build({
|
||||
entryPoints: [at('web', 'editor', 'index.js')],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
globalName: 'DevDeskEditor',
|
||||
target: ['es2020'],
|
||||
write: false,
|
||||
legalComments: 'eof',
|
||||
});
|
||||
const code = out.outputFiles[0].text;
|
||||
await mkdir(at('dist', 'assets'), { recursive: true });
|
||||
await writeFile(at('dist', 'assets', 'editor.js'), code);
|
||||
return { sha: createHash('sha256').update(code).digest('hex'), size: Buffer.byteLength(code) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ver = await version();
|
||||
const editor = await editorBundle();
|
||||
|
||||
const js = await build({
|
||||
entryPoints: [at('web', 'src', 'main.js')],
|
||||
@@ -44,7 +66,10 @@ async function main() {
|
||||
target: ['es2020'],
|
||||
write: false,
|
||||
legalComments: 'eof',
|
||||
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
|
||||
define: {
|
||||
__DEVDESK_VERSION__: JSON.stringify(ver),
|
||||
__EDITOR_SHA__: JSON.stringify(editor.sha),
|
||||
},
|
||||
});
|
||||
|
||||
const css = await transform([
|
||||
@@ -67,6 +92,7 @@ async function main() {
|
||||
await mkdir(at('dist'), { recursive: true });
|
||||
await writeFile(at('dist', 'index.html'), html);
|
||||
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
|
||||
console.log(`dist/assets/editor.js ${(editor.size / 1024).toFixed(0)} KiB sha256 ${editor.sha.slice(0, 12)}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// The file viewer's editor bundle (devdesk#19): CodeMirror 6 with stream-mode
|
||||
// languages, a unified diff view, and markdown rendering. Built separately to
|
||||
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
|
||||
// the first time the Files pane opens, so the page itself stays small.
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
|
||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { python } from '@codemirror/legacy-modes/mode/python';
|
||||
import { javascript, json, typescript } from '@codemirror/legacy-modes/mode/javascript';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties';
|
||||
import { nginx } from '@codemirror/legacy-modes/mode/nginx';
|
||||
import { diff as diffMode } from '@codemirror/legacy-modes/mode/diff';
|
||||
import { css } from '@codemirror/legacy-modes/mode/css';
|
||||
import { xml, html } from '@codemirror/legacy-modes/mode/xml';
|
||||
import { go } from '@codemirror/legacy-modes/mode/go';
|
||||
import { rust } from '@codemirror/legacy-modes/mode/rust';
|
||||
import { standardSQL } from '@codemirror/legacy-modes/mode/sql';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { lua } from '@codemirror/legacy-modes/mode/lua';
|
||||
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const MODES = {
|
||||
shell, python, javascript, json, typescript, yaml, toml, properties, nginx, diff: diffMode, css, xml, html,
|
||||
go, rust, sql: standardSQL, dockerfile: dockerFile, lua, perl, ruby,
|
||||
};
|
||||
|
||||
const BY_EXT = {
|
||||
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
|
||||
sh: 'shell', bash: 'shell', zsh: 'shell', ksh: 'shell', env: 'shell', bats: 'shell',
|
||||
py: 'python', pyi: 'python',
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
||||
ts: 'typescript', tsx: 'typescript', mts: 'typescript',
|
||||
json: 'json', jsonc: 'json', webmanifest: 'json',
|
||||
yml: 'yaml', yaml: 'yaml',
|
||||
toml: 'toml',
|
||||
ini: 'properties', cfg: 'properties', conf: 'properties', properties: 'properties',
|
||||
service: 'properties', timer: 'properties', socket: 'properties', mount: 'properties', network: 'properties',
|
||||
diff: 'diff', patch: 'diff',
|
||||
css: 'css', scss: 'css',
|
||||
xml: 'xml', svg: 'xml', plist: 'xml',
|
||||
html: 'html', htm: 'html', php: 'html', j2: 'html', jinja: 'html',
|
||||
go: 'go', rs: 'rust', sql: 'sql', lua: 'lua', pl: 'perl', pm: 'perl', rb: 'ruby',
|
||||
};
|
||||
|
||||
// Language for a path: extension, a few well-known names, else none.
|
||||
export function languageFor(path) {
|
||||
const name = path.split('/').pop().toLowerCase();
|
||||
if (name === 'dockerfile' || name.startsWith('dockerfile.') || name.endsWith('.dockerfile')) return 'dockerfile';
|
||||
if (name === 'makefile' || name === 'gnumakefile') return 'shell';
|
||||
if (/(^|\/)nginx\//.test(path) || name === 'nginx.conf') return 'nginx';
|
||||
if (['.bashrc', '.profile', '.bash_profile', '.zshrc', 'pkgbuild'].includes(name)) return 'shell';
|
||||
if (name.startsWith('.env')) return 'shell';
|
||||
const ext = name.includes('.') ? name.split('.').pop() : '';
|
||||
return BY_EXT[ext] ?? null;
|
||||
}
|
||||
|
||||
function languageExtension(lang) {
|
||||
if (lang === 'markdown') return markdownLanguage;
|
||||
if (lang && MODES[lang]) return StreamLanguage.define(MODES[lang]);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Colours come from the page's TDS variables, so dark/light mode just works.
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], color: 'var(--accent-orange)' },
|
||||
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--accent-green)' },
|
||||
{ tag: [t.comment, t.lineComment, t.blockComment, t.meta], color: 'var(--text-muted)', fontStyle: 'italic' },
|
||||
{ tag: [t.number, t.bool, t.atom, t.null], color: 'var(--accent-purple)' },
|
||||
{ tag: [t.typeName, t.className, t.namespace], color: 'var(--accent-amber)' },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], color: 'var(--accent-cyan)' },
|
||||
{ tag: [t.propertyName, t.attributeName], color: 'var(--accent-cyan)' },
|
||||
{ tag: [t.tagName], color: 'var(--accent-orange)' },
|
||||
{ tag: [t.heading, t.heading1, t.heading2, t.heading3], color: 'var(--accent-cyan)', fontWeight: 'bold' },
|
||||
{ tag: t.strong, fontWeight: 'bold' },
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
{ tag: t.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: [t.link, t.url], color: 'var(--accent-cyan)', textDecoration: 'underline' },
|
||||
{ tag: [t.monospace], color: 'var(--accent-amber)' },
|
||||
{ tag: [t.quote], color: 'var(--text-secondary)', fontStyle: 'italic' },
|
||||
{ tag: t.inserted, color: 'var(--accent-green)' },
|
||||
{ tag: t.deleted, color: 'var(--accent-red)' },
|
||||
{ tag: t.invalid, color: 'var(--accent-red)' },
|
||||
]);
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': { height: '100%', color: 'var(--text-primary)', backgroundColor: 'var(--bg-terminal)', fontSize: 'var(--dd-editor-font, 13px)' },
|
||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.5' },
|
||||
'.cm-content': { caretColor: 'var(--accent-orange)' },
|
||||
'.cm-cursor': { borderLeftColor: 'var(--accent-orange)' },
|
||||
'.cm-gutters': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-dim)', border: 'none', borderRight: '1px solid var(--border-dim)' },
|
||||
'.cm-activeLine': { backgroundColor: 'var(--accent-cyan-dim)' },
|
||||
'.cm-activeLineGutter': { backgroundColor: 'var(--accent-cyan-dim)', color: 'var(--accent-cyan)' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(0, 150, 220, 0.28) !important' },
|
||||
'.cm-searchMatch': { backgroundColor: 'var(--accent-amber-dim)', outline: '1px solid var(--accent-amber)' },
|
||||
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'var(--accent-orange-dim)' },
|
||||
'.cm-selectionMatch': { backgroundColor: 'var(--accent-cyan-dim)' },
|
||||
'.cm-panels': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', borderColor: 'var(--border-color)' },
|
||||
'.cm-panel input, .cm-panel button': { fontFamily: 'var(--font-mono)', fontSize: '12px' },
|
||||
'.cm-textfield': { backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
|
||||
'.cm-button': { backgroundImage: 'none', backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
|
||||
'.cm-foldGutter span': { color: 'var(--text-dim)' },
|
||||
// unified diff
|
||||
'.cm-changedLine': { backgroundColor: 'var(--accent-green-dim) !important' },
|
||||
'.cm-changedText': { backgroundColor: 'rgba(0, 200, 120, 0.22) !important' },
|
||||
'.cm-deletedChunk': { backgroundColor: 'var(--accent-red-dim) !important' },
|
||||
'.cm-deletedChunk .cm-deletedText, .cm-deletedChunk del': { backgroundColor: 'rgba(255, 45, 85, 0.22)', textDecoration: 'none' },
|
||||
'.cm-insertedLine': { backgroundColor: 'var(--accent-green-dim)' },
|
||||
'.cm-changeGutter': { width: '3px' },
|
||||
'.cm-changedLineGutter': { backgroundColor: 'var(--accent-green)' },
|
||||
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
||||
});
|
||||
|
||||
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
||||
return [
|
||||
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
||||
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
|
||||
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
|
||||
// Read-only document in an editable view: the caret, selection, arrow keys
|
||||
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
|
||||
EditorState.readOnly.of(readOnly),
|
||||
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
|
||||
wrap ? EditorView.lineWrapping : [],
|
||||
];
|
||||
}
|
||||
|
||||
// Read-only (v1) view of a file.
|
||||
export function viewer(parent, { doc, path, wrap }) {
|
||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
||||
return { view, destroy: () => view.destroy() };
|
||||
}
|
||||
|
||||
// Unified diff of the HEAD version against the working copy.
|
||||
export function diff(parent, { original, modified, path, wrap }) {
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: modified,
|
||||
extensions: [baseExtensions(path, { wrap }), unifiedMergeView({ original, mergeControls: false, highlightChanges: true, gutter: true })],
|
||||
}),
|
||||
});
|
||||
return { view, destroy: () => view.destroy() };
|
||||
}
|
||||
|
||||
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
|
||||
export function markdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
|
||||
}
|
||||
@@ -63,11 +63,13 @@
|
||||
</div>
|
||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
||||
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
|
||||
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
|
||||
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
|
||||
<div class="dd-menu-foot" id="version">devdesk</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-files-toggle" id="files-toggle" aria-controls="files" aria-expanded="false" title="Files: browse, preview and diff this session's folder (Ctrl+Shift+E)"><span class="dd-panels-label">Files</span><span class="dd-panels-icon" aria-hidden="true">🗀</span></button>
|
||||
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -88,6 +90,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<span class="dd-files-actions">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="dd-backdrop" id="backdrop" hidden></div>
|
||||
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
|
||||
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
|
||||
|
||||
@@ -258,6 +258,71 @@ async function uploadNow(session, file, onProgress) {
|
||||
});
|
||||
}
|
||||
|
||||
// Blob replies (`--fs-read`, `--git-show`, `--asset`): a JSON header line, then
|
||||
// base64 between DEVDESK-BLOB-BEGIN/END. Resolves { header, base64 }, base64
|
||||
// being null when the file has no body (too large, binary). Only the head and
|
||||
// the tail of the stream are decoded while it arrives, so megabytes stay cheap.
|
||||
export function runBlob(args, timeoutMs = 30000) {
|
||||
return limited(() => runBlobNow(args, timeoutMs));
|
||||
}
|
||||
|
||||
async function runBlobNow(args, timeoutMs) {
|
||||
const token = await fetchToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const chunks = [];
|
||||
const decoder = new TextDecoder();
|
||||
let head = '';
|
||||
let tail = '';
|
||||
let settled = false;
|
||||
const done = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
fn(value);
|
||||
};
|
||||
const timer = setTimeout(() => done(reject, new Error('devdesk did not answer in time')), timeoutMs);
|
||||
const finish = () => {
|
||||
const text = stripAnsi(new TextDecoder().decode(concat(chunks)));
|
||||
const error = /^devdesk: (.*)$/m.exec(text);
|
||||
const line = text.split(/\r?\n/).find((l) => l.startsWith('{'));
|
||||
if (!line) {
|
||||
done(reject, new Error(error ? error[1] : 'no reply'));
|
||||
return;
|
||||
}
|
||||
let header;
|
||||
try {
|
||||
header = JSON.parse(line);
|
||||
} catch {
|
||||
done(reject, new Error('unreadable reply'));
|
||||
return;
|
||||
}
|
||||
const body = /DEVDESK-BLOB-BEGIN\r?\n([\s\S]*?)\r?\nDEVDESK-BLOB-END/.exec(text);
|
||||
done(resolve, { header, base64: body ? body[1].replace(/\s+/g, '') : null });
|
||||
};
|
||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||
socket.onmessage = (event) => {
|
||||
const { type, payload } = decodeServer(event.data);
|
||||
if (type !== Server.OUTPUT) return;
|
||||
chunks.push(payload.slice());
|
||||
const piece = decoder.decode(payload, { stream: true });
|
||||
if (head.length < 4096) head += piece;
|
||||
tail = (tail + piece).slice(-256);
|
||||
const firstLine = /^\s*(\{.*\})\r?$/m.exec(head);
|
||||
if (tail.includes('DEVDESK-BLOB-END') || /^devdesk: .*\n/m.test(head)
|
||||
|| (firstLine && !head.includes('DEVDESK-BLOB-BEGIN') && /"tooLarge":true/.test(firstLine[1]))) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {};
|
||||
socket.onclose = () => {
|
||||
if (!settled) finish();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
|
||||
export async function fetchRepo(session) {
|
||||
return parseListOutput(await runCommand(['--repo', session]));
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
/* 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.
|
||||
|
||||
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';
|
||||
|
||||
// ---------------------------------------------------------------- editor bundle
|
||||
|
||||
const DB = 'devdesk';
|
||||
const STORE = 'assets';
|
||||
|
||||
function idb(mode, run) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const open = indexedDB.open(DB, 1);
|
||||
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
|
||||
open.onerror = () => reject(open.error);
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction(STORE, mode);
|
||||
const request = run(tx.objectStore(STORE));
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve(request?.result);
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function sha256(text) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function decodeBase64(b64) {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// The editor (~600 KB) is not in the page: it's fetched over ttyd the first time
|
||||
// the pane opens, checked against the hash this page was built with, cached in
|
||||
// IndexedDB under that hash, and run.
|
||||
let editorLoading = null;
|
||||
export function loadEditor() {
|
||||
editorLoading ??= (async () => {
|
||||
if (window.DevDeskEditor) return window.DevDeskEditor;
|
||||
let code = null;
|
||||
try {
|
||||
code = await idb('readonly', (store) => store.get(__EDITOR_SHA__));
|
||||
} catch {
|
||||
// No IndexedDB (private mode): fetch every time.
|
||||
}
|
||||
if (!code) {
|
||||
const { base64 } = await runBlob(['--asset', 'editor.js'], 60000);
|
||||
if (!base64) throw new Error('the editor bundle is missing on the server');
|
||||
code = new TextDecoder().decode(decodeBase64(base64));
|
||||
if (crypto.subtle && (await sha256(code)) !== __EDITOR_SHA__) {
|
||||
throw new Error('the server has a different DevDesk version: reload the page');
|
||||
}
|
||||
idb('readwrite', (store) => {
|
||||
store.clear();
|
||||
store.put(code, __EDITOR_SHA__);
|
||||
}).catch(() => {});
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.textContent = code;
|
||||
document.head.append(script);
|
||||
script.remove();
|
||||
if (!window.DevDeskEditor) throw new Error('the editor failed to start');
|
||||
return window.DevDeskEditor;
|
||||
})().catch((error) => {
|
||||
editorLoading = null;
|
||||
throw error;
|
||||
});
|
||||
return editorLoading;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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);
|
||||
|
||||
const STATUS = {
|
||||
M: ['M', 'modified', 'amber'], A: ['A', 'added', 'green'], D: ['D', 'deleted', 'red'], R: ['R', 'renamed', 'cyan'],
|
||||
C: ['C', 'copied', 'cyan'], U: ['U', 'conflict', 'red'], '?': ['N', 'new', 'green'], ' ': ['·', 'edited by Claude', 'cyan'],
|
||||
};
|
||||
function statusOf(f) {
|
||||
const code = f.x === '?' ? '?' : f.x !== ' ' ? f.x : f.y;
|
||||
return STATUS[code] ?? STATUS.M;
|
||||
}
|
||||
|
||||
// Markdown preview document: sanitized HTML in a sandboxed iframe (no scripts,
|
||||
// no same-origin), styled from the page's current TDS colours.
|
||||
function previewDocument(html) {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const v = (name) => css.getPropertyValue(name).trim();
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
|
||||
:root { color-scheme: ${document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}; }
|
||||
body { margin: 0; padding: 18px 22px 40px; background: ${v('--bg-terminal')}; color: ${v('--text-primary')};
|
||||
font: 14px/1.65 ${v('--font-mono') || 'monospace'}; overflow-wrap: anywhere; }
|
||||
h1, h2, h3, h4 { color: ${v('--accent-cyan')}; line-height: 1.3; margin: 1.4em 0 0.5em; }
|
||||
h1 { font-size: 1.45em; border-bottom: 1px solid ${v('--border-color')}; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.2em; border-bottom: 1px solid ${v('--border-dim')}; padding-bottom: 0.2em; }
|
||||
a { color: ${v('--accent-orange')}; }
|
||||
code { background: ${v('--bg-tertiary')}; padding: 1px 5px; color: ${v('--accent-amber')}; font-family: inherit; }
|
||||
pre { background: ${v('--bg-secondary')}; border: 1px solid ${v('--border-dim')}; padding: 10px 12px; overflow-x: auto; }
|
||||
pre code { background: none; padding: 0; color: ${v('--text-primary')}; }
|
||||
blockquote { margin: 0.8em 0; padding: 2px 14px; border-left: 3px solid ${v('--accent-cyan')}; color: ${v('--text-secondary')}; }
|
||||
table { border-collapse: collapse; margin: 0.8em 0; } th, td { border: 1px solid ${v('--border-color')}; padding: 4px 10px; }
|
||||
th { background: ${v('--bg-secondary')}; } hr { border: 0; border-top: 1px solid ${v('--border-color')}; }
|
||||
img { max-width: 100%; } li { margin: 0.2em 0; } input[type=checkbox] { margin-right: 6px; }
|
||||
</style></head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
// Relative <img> in a markdown file (e.g. docs/screenshot.png) can't load from
|
||||
// the sandboxed preview: fetch up to 10 of them over ttyd and inline them.
|
||||
async function inlineImages(html, session, mdPath) {
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
const images = [...doc.querySelectorAll('img[src]')]
|
||||
.filter((img) => !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(img.getAttribute('src'))).slice(0, 10);
|
||||
if (!images.length) return html;
|
||||
const base = parent(mdPath);
|
||||
await Promise.all(images.map(async (img) => {
|
||||
const parts = [];
|
||||
for (const part of `${base}/${decodeURIComponent(img.getAttribute('src').split(/[?#]/)[0])}`.split('/')) {
|
||||
if (part === '..') parts.pop();
|
||||
else if (part && part !== '.') parts.push(part);
|
||||
}
|
||||
try {
|
||||
const { header, base64 } = await runBlob(['--fs-read', session, parts.join('/')]);
|
||||
if (header.mime?.startsWith('image/') && base64) img.setAttribute('src', `data:${header.mime};base64,${base64}`);
|
||||
} catch {
|
||||
// missing image: leave the broken link
|
||||
}
|
||||
}));
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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.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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return !this.root.hidden;
|
||||
}
|
||||
|
||||
open(tab) {
|
||||
if (tab) this.tab = tab;
|
||||
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
|
||||
}
|
||||
|
||||
close() {
|
||||
this.root.hidden = true;
|
||||
this.hooks.onToggle(false);
|
||||
this.hooks.focusTerminal();
|
||||
}
|
||||
|
||||
toggle(tab) {
|
||||
if (this.isOpen && (!tab || tab === this.tab)) this.close();
|
||||
else this.open(tab);
|
||||
}
|
||||
|
||||
// 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;
|
||||
this.changes = null;
|
||||
this.destroyEditor();
|
||||
this.current = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
message(text, tone = '') {
|
||||
this.crumbs.replaceChildren();
|
||||
this.body.replaceChildren(el('div', { class: `dd-files-msg${tone ? ` is-${tone}` : ''}` }, text));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ files tab
|
||||
|
||||
async loadDir(dir) {
|
||||
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…'));
|
||||
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();
|
||||
} catch (error) {
|
||||
if (seq !== this.seq) return;
|
||||
if (dir) {
|
||||
toast.error(`${dir}: ${error.message}`);
|
||||
this.dir = '';
|
||||
this.loadDir('');
|
||||
} else {
|
||||
this.message(error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
this.renderTabs();
|
||||
this.loadDir(dir);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 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;
|
||||
}
|
||||
|
||||
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');
|
||||
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.'));
|
||||
return;
|
||||
}
|
||||
const rows = files.map((f) => {
|
||||
const [letter, label, tone] = statusOf(f);
|
||||
const name = f.path ?? f.repoPath;
|
||||
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'),
|
||||
},
|
||||
el('span', { class: `dd-file-status is-${tone}` }, letter),
|
||||
el('span', { class: 'dd-file-name' }, name),
|
||||
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)));
|
||||
});
|
||||
this.body.replaceChildren(el('ul', { class: 'dd-file-list' }, ...rows));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ viewer
|
||||
|
||||
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);
|
||||
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');
|
||||
return;
|
||||
}
|
||||
if (session !== this.session || this.current?.path !== path) return;
|
||||
const { header, base64 } = blob;
|
||||
this.current.sha = header.sha ?? null;
|
||||
|
||||
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 }));
|
||||
} 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') {
|
||||
content.classList.add('is-preview');
|
||||
content.append(el('iframe', {
|
||||
class: 'dd-md-frame', title: `Preview of ${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) => {
|
||||
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 {
|
||||
this.editorView = editor.viewer(content, { doc: text(base64, header.utf8), 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();
|
||||
}
|
||||
|
||||
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.
|
||||
themeChanged() {
|
||||
if (this.current?.mode === 'preview') this.openFile(this.current.path, 'preview');
|
||||
}
|
||||
}
|
||||
+83
-1
@@ -12,6 +12,7 @@ import { bindAskClaude } from './widgets.js';
|
||||
import { heardWithin, onShared, share } from './shared.js';
|
||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||
import { Inbox } from './inbox.js';
|
||||
import { FilesPane } from './files.js';
|
||||
import { el } from './dom.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
@@ -75,6 +76,7 @@ let fit;
|
||||
let sessionsPanel;
|
||||
let palette;
|
||||
let inbox;
|
||||
let files;
|
||||
let lastRepo = null;
|
||||
let lastAlerts = null;
|
||||
let waitingOthers = [];
|
||||
@@ -330,6 +332,7 @@ function connect(session, { reconnect = false } = {}) {
|
||||
paneTitleRaw = '';
|
||||
renderPaneTitle();
|
||||
onSessionData();
|
||||
files?.sessionChanged();
|
||||
hideOverlay();
|
||||
setWsState('connecting', 'connecting');
|
||||
state.openedAt = 0;
|
||||
@@ -465,6 +468,7 @@ function applyTitle() {
|
||||
sessionsPanel.setCurrent(parsed.session);
|
||||
onSessionData();
|
||||
refreshRepo();
|
||||
files?.sessionChanged();
|
||||
}
|
||||
paneTitleRaw = parsed.pane;
|
||||
renderPaneTitle();
|
||||
@@ -536,6 +540,7 @@ function setFontSize(size) {
|
||||
const clamped = Math.min(24, Math.max(10, size));
|
||||
term.options.fontSize = clamped;
|
||||
store.set('fontSize', clamped);
|
||||
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
|
||||
$('font-size').textContent = String(clamped);
|
||||
fit.fit();
|
||||
}
|
||||
@@ -607,6 +612,10 @@ function buildCommands() {
|
||||
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
||||
}
|
||||
|
||||
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: 'theme', group: 'View', icon: getTheme() === 'light' ? '◐' : '☀', keywords: 'theme light dark mode colour color',
|
||||
label: getTheme() === 'light' ? 'Switch to dark mode' : 'Switch to light mode', run: toggleTheme,
|
||||
@@ -666,6 +675,7 @@ const KEY_HELP = [
|
||||
]],
|
||||
['DevDesk', [
|
||||
['Ctrl+⇧ P', 'Command palette'],
|
||||
['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
|
||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||
['⇧ drag', 'Select text in the browser'],
|
||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||
@@ -681,6 +691,41 @@ function showHelp() {
|
||||
openDialog({ title: 'Keyboard shortcuts', body, actions: [{ label: 'Close', primary: true }] }).then(() => term.focus());
|
||||
}
|
||||
|
||||
// Drag the files pane's left edge (or use ←/→ on it). The terminal keeps at
|
||||
// least ~60 columns; the width is remembered per browser.
|
||||
function wireResizer() {
|
||||
const main = document.querySelector('.dd-main');
|
||||
const handle = $('files-resizer');
|
||||
const apply = (px) => {
|
||||
const max = main.clientWidth - 520;
|
||||
const width = Math.round(Math.max(340, Math.min(max, px)));
|
||||
main.style.setProperty('--dd-files-w', `${width}px`);
|
||||
return width;
|
||||
};
|
||||
const saved = Number(store.get('filesWidth', 0));
|
||||
if (saved) apply(saved);
|
||||
handle.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
ui.body.classList.add('dd-resizing');
|
||||
const move = (e) => apply(main.getBoundingClientRect().right - e.clientX);
|
||||
const up = () => {
|
||||
ui.body.classList.remove('dd-resizing');
|
||||
handle.removeEventListener('pointermove', move);
|
||||
handle.removeEventListener('pointerup', up);
|
||||
store.set('filesWidth', $('files').getBoundingClientRect().width);
|
||||
};
|
||||
handle.addEventListener('pointermove', move);
|
||||
handle.addEventListener('pointerup', up);
|
||||
});
|
||||
handle.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||
event.preventDefault();
|
||||
const width = apply($('files').getBoundingClientRect().width + (event.key === 'ArrowLeft' ? 40 : -40));
|
||||
store.set('filesWidth', width);
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
@@ -704,6 +749,7 @@ function renderNotifyButton() {
|
||||
}
|
||||
|
||||
function setSidebar(open) {
|
||||
if (open && files?.isOpen) files.close();
|
||||
ui.body.classList.toggle('dd-sidebar-open', open);
|
||||
ui.sidebarButton.setAttribute('aria-expanded', String(open));
|
||||
ui.backdrop.hidden = !(open && isNarrow());
|
||||
@@ -765,10 +811,34 @@ function wireChrome() {
|
||||
showHelp();
|
||||
});
|
||||
$('footer-help').addEventListener('click', showHelp);
|
||||
$('palette-menu').addEventListener('click', () => {
|
||||
closeMenus();
|
||||
palette.open();
|
||||
});
|
||||
files = new FilesPane($('files'), {
|
||||
session: () => state.session,
|
||||
paste: (text) => {
|
||||
term.paste(text);
|
||||
},
|
||||
focusTerminal: () => term.focus(),
|
||||
onToggle: (open) => {
|
||||
ui.body.classList.toggle('dd-files-open', open);
|
||||
$('files-toggle').setAttribute('aria-expanded', String(open));
|
||||
if (open && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
|
||||
},
|
||||
});
|
||||
$('files-toggle').addEventListener('click', () => files.toggle());
|
||||
wireResizer();
|
||||
palette = new CommandPalette(buildCommands);
|
||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||
// Ctrl+Shift+P (⌘⇧P): Ctrl+K belongs to the terminal (kill-line in Claude and bash).
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyE') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.session) files.toggle();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -819,6 +889,14 @@ function wireChrome() {
|
||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
||||
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
||||
// own search panel has the focus.
|
||||
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
files.escape();
|
||||
return;
|
||||
}
|
||||
const menuOpen = menus.some(({ menu }) => !menu.hidden) || inbox.isOpen;
|
||||
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
|
||||
if (!menuOpen && !drawerOpen) return;
|
||||
@@ -901,7 +979,10 @@ async function boot() {
|
||||
theme: getTheme(),
|
||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||
}));
|
||||
onThemeChange((theme) => { term.options.theme = THEMES[theme]; });
|
||||
onThemeChange((theme) => {
|
||||
term.options.theme = THEMES[theme];
|
||||
files?.themeChanged();
|
||||
});
|
||||
$('theme-toggle').addEventListener('click', toggleTheme);
|
||||
term.onData((data) => state.conn?.sendInput(data));
|
||||
term.onBinary((data) => state.conn?.sendBinary(data));
|
||||
@@ -914,6 +995,7 @@ async function boot() {
|
||||
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
|
||||
refreshSoon(); // Claude finished a turn: states and context just changed
|
||||
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
|
||||
setTimeout(() => files?.claudeTurnEnded(), 1200); // …or edited files
|
||||
});
|
||||
|
||||
let fitFrame = 0;
|
||||
|
||||
+131
@@ -699,3 +699,134 @@ html[data-theme="light"] .lt-notif-dot { box-shadow: none; }
|
||||
.dd-panels-icon { display: inline; font-size: 1rem; }
|
||||
.dd-inbox-panel { position: fixed; top: calc(var(--header-height) + 4px); right: 8px; left: 8px; width: auto; }
|
||||
}
|
||||
|
||||
/* ---- files pane (devdesk#19) -------------------------------------------------------- */
|
||||
.dd-files {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--dd-files-w, 46%);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
background: var(--bg-terminal);
|
||||
}
|
||||
.dd-files[hidden] { display: none; }
|
||||
body.dd-resizing { cursor: col-resize; user-select: none; }
|
||||
body.dd-resizing .dd-terminal { pointer-events: none; }
|
||||
.dd-files-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(-1 * var(--dd-gap) - 2px);
|
||||
width: calc(var(--dd-gap) + 6px);
|
||||
cursor: col-resize;
|
||||
z-index: 3;
|
||||
}
|
||||
.dd-files-resizer:hover,
|
||||
.dd-files-resizer:focus-visible,
|
||||
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;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
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-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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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-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-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; }
|
||||
.dd-viewer-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
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 > .cm-editor { flex: 1; min-height: 0; }
|
||||
.dd-viewer-content .cm-editor.cm-focused { outline: none; }
|
||||
.dd-viewer-content.is-image {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: repeating-conic-gradient(var(--bg-secondary) 0 25%, var(--bg-terminal) 0 50%) 0 0 / 20px 20px;
|
||||
}
|
||||
.dd-viewer-content.is-image img { max-width: 100%; height: auto; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 1099px) {
|
||||
/* Narrow: 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user