dsh-better-sidebar: VSCode-like right sidebar plugin for the DSH web GUI

Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/
write, git status/diff/stage/commit/branch/log/show), /sidebar/file media
route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api
trust fence (Host-header loopback or connection.trustedHosts).

Client half (src/client): portal-mounted right panel with a per-session
split-pane workbench (recursive row/col splits, draggable dividers, tab
drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal
multi-open rules), lazy file explorer rooted at the session cwd, editable
text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm
terminal with reconnect, and a VSCode-style git panel. Per-session state
persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat
turn-tail produced-files row (priority -1 chain entry) to open files in the
sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives.

Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle
(externals = module table words; xterm/css inlined). Tests: 19 unit + smoke
specs (pure state/tree/git parsers + real git/fs integration).
This commit is contained in:
Menghuan1918
2026-08-08 01:02:24 +08:00
parent 4f4e462f98
commit 63e2988ef1
31 changed files with 5807 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId; failures surface as
* {@link SidebarApiError} with the wire error code.
*/
/** One wire failure. */
export class SidebarApiError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message)
}
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
xy: string
}
/** Git status snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
}
/** One git log row. */
export interface GitLogEntry {
hash: string
subject: string
author: string
date: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route). */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean }
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
response = await fetch(`/sidebar/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
/** The sidebar API surface (sessionId is threaded through every call). */
export const api = {
sessionCwd: (sessionId: string, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', { sessionId }, signal),
fsTree: (sessionId: string, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', { sessionId, path }, signal),
fsRead: (sessionId: string, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', { sessionId, path }, signal),
fsWrite: (sessionId: string, path: string, content: string) =>
call<{ ok: true }>('fs.write', { sessionId, path, content }),
gitStatus: (sessionId: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', { sessionId }, signal),
gitDiff: (sessionId: string, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', { sessionId, ...(path !== undefined ? { path } : {}), staged }, signal),
gitShow: (sessionId: string, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', { sessionId, rev, path }, signal),
gitStage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.stage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitUnstage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.unstage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitCommit: (sessionId: string, message: string) =>
call<{ ok: true }>('git.commit', { sessionId, message }),
gitBranch: (sessionId: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', { sessionId }, signal),
gitCheckout: (sessionId: string, branch: string) =>
call<{ ok: true }>('git.checkout', { sessionId, branch }),
gitLog: (sessionId: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', { sessionId }, signal),
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(sessionId: string, path: string): string {
return `/sidebar/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`
}