dsh-better-sidebar 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/README_EN.md +139 -0
- package/lib/client-docx.js +7649 -0
- package/lib/client-editor.js +32677 -0
- package/lib/client-pptx.js +63438 -0
- package/lib/client-registry.js +8021 -0
- package/lib/client-terminal.js +8060 -0
- package/lib/client-xlsx.js +359755 -0
- package/lib/client.js +8021 -0
- package/lib/index.js +2604 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +220 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +13 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +10 -0
- package/lib/types/client/ExplorerView.d.ts +9 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/PptxView.d.ts +6 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +27 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +38 -0
- package/lib/types/client/TerminalView.d.ts +8 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/api.d.ts +170 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +17 -0
- package/lib/types/client/builtins/tabs.d.ts +6 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +74 -0
- package/lib/types/client/chunks/docx.d.ts +8 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/pptx.d.ts +8 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/chunks/xlsx.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/docx-view.d.ts +8 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/icons.d.ts +47 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/intercept.d.ts +20 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +35 -0
- package/lib/types/client/locales.d.ts +237 -0
- package/lib/types/client/office-shared.d.ts +33 -0
- package/lib/types/client/office-types.d.ts +36 -0
- package/lib/types/client/office-view.d.ts +12 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +19 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/prefs.d.ts +30 -0
- package/lib/types/client/produced-files.d.ts +27 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +214 -0
- package/lib/types/client/split-pane.d.ts +29 -0
- package/lib/types/client/state.d.ts +322 -0
- package/lib/types/client/subagent-activity.d.ts +35 -0
- package/lib/types/client/subagent-detect.d.ts +47 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/theme.d.ts +28 -0
- package/lib/types/client/xlsx-to-univer.d.ts +33 -0
- package/lib/types/client/xlsx-view.d.ts +10 -0
- package/lib/types/config.d.ts +41 -0
- package/lib/types/context-types.d.ts +431 -0
- package/lib/types/fs-tree.d.ts +41 -0
- package/lib/types/git.d.ts +75 -0
- package/lib/types/html-route.d.ts +48 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/prefs-shared.d.ts +106 -0
- package/lib/types/pty-manager.d.ts +76 -0
- package/lib/types/tools.d.ts +28 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +168 -0
- package/src/agent-pty.ts +519 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +130 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +266 -0
- package/src/client/EditorHost.tsx +101 -0
- package/src/client/ExplorerView.tsx +283 -0
- package/src/client/GitView.tsx +540 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/PptxView.tsx +131 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +271 -0
- package/src/client/SideCardSection.tsx +426 -0
- package/src/client/Sidebar.tsx +861 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +866 -0
- package/src/client/TabBar.tsx +188 -0
- package/src/client/TerminalView.tsx +233 -0
- package/src/client/TextEditor.tsx +382 -0
- package/src/client/api.ts +215 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +33 -0
- package/src/client/builtins/tabs.tsx +211 -0
- package/src/client/builtins/viewers.tsx +151 -0
- package/src/client/chunk-loader.ts +196 -0
- package/src/client/chunks/docx.tsx +8 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/pptx.tsx +8 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/chunks/xlsx.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/docx-view.tsx +113 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/icons.tsx +137 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +238 -0
- package/src/client/intercept.tsx +94 -0
- package/src/client/lang.ts +95 -0
- package/src/client/layout.css +54 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +66 -0
- package/src/client/locales.ts +476 -0
- package/src/client/office-shared.tsx +40 -0
- package/src/client/office-types.ts +48 -0
- package/src/client/office-view.tsx +12 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +28 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/prefs.ts +100 -0
- package/src/client/produced-files.ts +87 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +434 -0
- package/src/client/sidebar.module.css +1917 -0
- package/src/client/split-pane.tsx +292 -0
- package/src/client/state.ts +1048 -0
- package/src/client/subagent-activity.ts +67 -0
- package/src/client/subagent-detect.ts +125 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/theme.ts +44 -0
- package/src/client/xlsx-to-univer.ts +260 -0
- package/src/client/xlsx-view.tsx +116 -0
- package/src/config.ts +95 -0
- package/src/context-types.ts +445 -0
- package/src/fs-tree.ts +115 -0
- package/src/git.ts +236 -0
- package/src/html-route.ts +70 -0
- package/src/index.ts +897 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/prefs-shared.ts +127 -0
- package/src/pty-manager.ts +199 -0
- package/src/tools.ts +477 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +94 -0
package/src/fs-tree.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-level directory listing for the sidebar explorer. Streams the level
|
|
3
|
+
* with opendir, sorts directories first then names (case-insensitive), and
|
|
4
|
+
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
|
5
|
+
* reported as files without probing their target — the explorer shows what
|
|
6
|
+
* dirent says, keeping the read cheap for arbitrarily large levels.
|
|
7
|
+
*/
|
|
8
|
+
import { opendir } from 'node:fs/promises'
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
10
|
+
import { SidebarError } from './wire.ts'
|
|
11
|
+
|
|
12
|
+
/** One explorer row. */
|
|
13
|
+
export interface SidebarFsEntry {
|
|
14
|
+
name: string
|
|
15
|
+
path: string
|
|
16
|
+
isDir: boolean
|
|
17
|
+
hidden: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One listed level. */
|
|
21
|
+
export interface SidebarFsListing {
|
|
22
|
+
path: string
|
|
23
|
+
entries: SidebarFsEntry[]
|
|
24
|
+
truncated: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
|
|
28
|
+
export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
|
|
29
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
30
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List one directory level.
|
|
35
|
+
* @param path - absolute directory path.
|
|
36
|
+
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
|
|
37
|
+
* @returns the sorted listing.
|
|
38
|
+
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
|
|
39
|
+
*/
|
|
40
|
+
export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
|
|
41
|
+
let level
|
|
42
|
+
try {
|
|
43
|
+
level = await opendir(path)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
|
46
|
+
}
|
|
47
|
+
const rows: SidebarFsEntry[] = []
|
|
48
|
+
let overflow = 0
|
|
49
|
+
try {
|
|
50
|
+
for await (const dirent of level) {
|
|
51
|
+
if (rows.length >= maxEntries) {
|
|
52
|
+
overflow += 1
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
rows.push({
|
|
56
|
+
name: dirent.name,
|
|
57
|
+
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
|
58
|
+
// would leak mixed separators into every row's path.
|
|
59
|
+
path: join(path, dirent.name),
|
|
60
|
+
isDir: dirent.isDirectory(),
|
|
61
|
+
hidden: dirent.name.startsWith('.'),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
|
66
|
+
}
|
|
67
|
+
rows.sort(compareEntries)
|
|
68
|
+
return { path, entries: rows, truncated: overflow > 0 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
|
|
72
|
+
export function rootLabel(path: string): string {
|
|
73
|
+
const base = basename(path)
|
|
74
|
+
return base !== '' ? base : path
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
|
|
78
|
+
export function parentOf(path: string): string | undefined {
|
|
79
|
+
const parent = dirname(path)
|
|
80
|
+
return parent === path ? undefined : parent
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
|
84
|
+
export function requireAbsolute(path: string): string {
|
|
85
|
+
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
|
86
|
+
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
|
87
|
+
}
|
|
88
|
+
return resolve(path)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `target` lies under `base` (or equals it), tolerant of separator
|
|
93
|
+
* style and — on Windows, where the filesystem is case-insensitive — of
|
|
94
|
+
* letter case. The media route uses this instead of a raw `startsWith` so a
|
|
95
|
+
* case-mismatched or mixed-separator path can never be misclassified
|
|
96
|
+
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
|
|
97
|
+
* @param platform - filesystem semantics; injectable so both branches are
|
|
98
|
+
* unit-testable on any host.
|
|
99
|
+
*/
|
|
100
|
+
export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
101
|
+
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
|
|
102
|
+
const b = norm(base)
|
|
103
|
+
const t = norm(target)
|
|
104
|
+
if (platform === 'win32') {
|
|
105
|
+
const lb = b.toLowerCase()
|
|
106
|
+
const lt = t.toLowerCase()
|
|
107
|
+
return lt === lb || lt.startsWith(`${lb}/`)
|
|
108
|
+
}
|
|
109
|
+
return t === b || t.startsWith(`${b}/`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Message text of an unknown thrown value. */
|
|
113
|
+
export function messageOf(error: unknown): string {
|
|
114
|
+
return error instanceof Error ? error.message : String(error)
|
|
115
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git operations for the sidebar source-control panel. Everything goes
|
|
3
|
+
* through the system `git` binary spawned per request (no library, no state),
|
|
4
|
+
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
|
|
5
|
+
* so parsing never depends on locale or color config. All commands run with
|
|
6
|
+
* `-C <cwd>` on the session's working directory and `--no-pager` /
|
|
7
|
+
* `-c color.ui=false` so output stays machine-readable.
|
|
8
|
+
*
|
|
9
|
+
* Commits use the user's git global identity untouched (never sets
|
|
10
|
+
* user.name/user.email).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
/** A parsed `git status --porcelain=v1 -z` entry. */
|
|
15
|
+
export interface GitStatusEntry {
|
|
16
|
+
path: string
|
|
17
|
+
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
|
|
18
|
+
xy: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The source-control panel snapshot. */
|
|
22
|
+
export interface GitStatusResult {
|
|
23
|
+
isRepo: boolean
|
|
24
|
+
branch?: string
|
|
25
|
+
entries: GitStatusEntry[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One `git log` row. */
|
|
29
|
+
export interface GitLogEntry {
|
|
30
|
+
/** Short hash (7+ chars, display). */
|
|
31
|
+
hash: string
|
|
32
|
+
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
|
|
33
|
+
hashFull: string
|
|
34
|
+
subject: string
|
|
35
|
+
author: string
|
|
36
|
+
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
|
|
37
|
+
date: string
|
|
38
|
+
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
|
|
39
|
+
refs: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One git failure (stderr text as the message). */
|
|
43
|
+
export class GitCommandError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
readonly code = 'git-error',
|
|
47
|
+
readonly command: string,
|
|
48
|
+
) {
|
|
49
|
+
super(message)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
|
|
54
|
+
export function parsePorcelainZ(output: string): GitStatusEntry[] {
|
|
55
|
+
const tokens = output.split('\0')
|
|
56
|
+
const entries: GitStatusEntry[] = []
|
|
57
|
+
let index = 0
|
|
58
|
+
while (index < tokens.length) {
|
|
59
|
+
const token = tokens[index]!
|
|
60
|
+
index += 1
|
|
61
|
+
if (token === '') continue
|
|
62
|
+
const xy = token.slice(0, 2)
|
|
63
|
+
const rest = token.slice(3)
|
|
64
|
+
entries.push({ path: rest, xy })
|
|
65
|
+
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
|
|
66
|
+
// new path (the file as it exists now) is the display path.
|
|
67
|
+
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
|
|
68
|
+
index += 1
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
|
|
75
|
+
export function parseLogLines(output: string): GitLogEntry[] {
|
|
76
|
+
const rows: GitLogEntry[] = []
|
|
77
|
+
for (const line of output.split('\n')) {
|
|
78
|
+
if (line === '') continue
|
|
79
|
+
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
|
|
80
|
+
if (hash === undefined || subject === undefined) continue
|
|
81
|
+
rows.push({
|
|
82
|
+
hash,
|
|
83
|
+
subject,
|
|
84
|
+
author: author ?? '',
|
|
85
|
+
date: date ?? '',
|
|
86
|
+
hashFull: hashFull ?? hash,
|
|
87
|
+
refs: refs ?? '',
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
return rows
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
|
|
94
|
+
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
|
|
95
|
+
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
|
|
96
|
+
return new Promise<string>((resolvePromise, reject) => {
|
|
97
|
+
const child = spawn('git', full, {
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
100
|
+
})
|
|
101
|
+
let stdout = ''
|
|
102
|
+
let stderr = ''
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
child.kill('SIGKILL')
|
|
105
|
+
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
|
|
106
|
+
}, timeoutMs)
|
|
107
|
+
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
|
108
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
|
109
|
+
child.on('error', (error) => {
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
|
|
112
|
+
})
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
clearTimeout(timer)
|
|
115
|
+
if (code === 0) {
|
|
116
|
+
resolvePromise(stdout)
|
|
117
|
+
} else {
|
|
118
|
+
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
|
|
125
|
+
export async function isGitRepo(cwd: string): Promise<boolean> {
|
|
126
|
+
try {
|
|
127
|
+
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
|
|
128
|
+
return out.trim() === 'true'
|
|
129
|
+
} catch {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
|
|
135
|
+
export async function repoRoot(cwd: string): Promise<string> {
|
|
136
|
+
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
137
|
+
return out.trim()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
|
|
141
|
+
export async function currentBranch(cwd: string): Promise<string> {
|
|
142
|
+
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
143
|
+
return out.trim()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Working-tree status (untracked included). */
|
|
147
|
+
export async function status(cwd: string): Promise<GitStatusResult> {
|
|
148
|
+
const repo = await isGitRepo(cwd)
|
|
149
|
+
if (!repo) return { isRepo: false, entries: [] }
|
|
150
|
+
const [branch, raw] = await Promise.all([
|
|
151
|
+
currentBranch(cwd).catch(() => 'HEAD'),
|
|
152
|
+
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
|
|
153
|
+
])
|
|
154
|
+
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Diff text of the worktree (unstaged) or the index (staged). */
|
|
158
|
+
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
|
|
159
|
+
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
|
|
160
|
+
if (staged) args.push('--cached')
|
|
161
|
+
if (path !== undefined) args.push('--', path)
|
|
162
|
+
return runGit(cwd, args)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Stage paths (all when path is undefined). */
|
|
166
|
+
export async function stage(cwd: string, path: string | undefined): Promise<void> {
|
|
167
|
+
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Unstage paths (all when path is undefined). */
|
|
171
|
+
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
|
|
172
|
+
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Commit the staged changes with a message (global identity untouched). */
|
|
176
|
+
export async function commit(cwd: string, message: string): Promise<void> {
|
|
177
|
+
await runGit(cwd, ['commit', '-m', message])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Branch names (current first). */
|
|
181
|
+
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
|
|
182
|
+
const [current, raw] = await Promise.all([
|
|
183
|
+
currentBranch(cwd).catch(() => 'HEAD'),
|
|
184
|
+
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
|
|
185
|
+
])
|
|
186
|
+
const names = raw.split('\n').filter(line => line !== '')
|
|
187
|
+
return { current, names: names.includes(current) ? names : [current, ...names] }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Switch to an existing branch. */
|
|
191
|
+
export async function checkout(cwd: string, branch: string): Promise<void> {
|
|
192
|
+
await runGit(cwd, ['checkout', branch])
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Recent commit history (newest first), lazily pageable via skip/count. */
|
|
196
|
+
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
|
|
197
|
+
const raw = await runGit(cwd, [
|
|
198
|
+
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
|
|
199
|
+
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
|
|
200
|
+
])
|
|
201
|
+
return parseLogLines(raw)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
|
|
206
|
+
* revision has no such path (a new/untracked file has no HEAD side).
|
|
207
|
+
*/
|
|
208
|
+
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
|
|
209
|
+
try {
|
|
210
|
+
return await runGit(cwd, ['show', `${rev}:${path}`])
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
217
|
+
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
218
|
+
* is a no-op for regular commits), so a history click always has content. */
|
|
219
|
+
export async function commitDiff(cwd: string, hash: string): Promise<string> {
|
|
220
|
+
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
|
|
224
|
+
export async function discard(cwd: string, path: string): Promise<void> {
|
|
225
|
+
await runGit(cwd, ['checkout', '--', path])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Revert one commit onto the current branch with an auto-generated message. */
|
|
229
|
+
export async function revert(cwd: string, hash: string): Promise<void> {
|
|
230
|
+
await runGit(cwd, ['revert', '--no-edit', hash])
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Cherry-pick one commit onto the current branch. */
|
|
234
|
+
export async function cherryPick(cwd: string, hash: string): Promise<void> {
|
|
235
|
+
await runGit(cwd, ['cherry-pick', hash])
|
|
236
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
|
|
3
|
+
*
|
|
4
|
+
* Why path-encoded parameters instead of a query string: the previewed
|
|
5
|
+
* page resolves its relative assets (./style.css, img/x.png) against the
|
|
6
|
+
* document URL, and the WHATWG URL algorithm DROPS the query of a
|
|
7
|
+
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
|
|
8
|
+
* would lose the session scope and the route would reject the asset.
|
|
9
|
+
* Encoding everything into the URL path keeps relative resolution inside
|
|
10
|
+
* the same route with every request self-contained:
|
|
11
|
+
*
|
|
12
|
+
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
|
|
13
|
+
* /sidebar/html/S/Users/me/proj/index.html
|
|
14
|
+
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
|
|
15
|
+
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
|
|
16
|
+
*
|
|
17
|
+
* This module is intentionally dependency-free (no node imports, no wire
|
|
18
|
+
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
|
|
19
|
+
* the build-time purity gate; the host converts decode failures into
|
|
20
|
+
* SidebarError responses at the route boundary.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One decoded route reference. */
|
|
24
|
+
export interface HtmlRouteRef {
|
|
25
|
+
sessionId: string
|
|
26
|
+
/** Absolute file path (leading slash; Windows drives keep their colon). */
|
|
27
|
+
path: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Decode outcome: the reference, or a client-error description. */
|
|
31
|
+
export type HtmlDecodeResult =
|
|
32
|
+
| { ok: true; ref: HtmlRouteRef }
|
|
33
|
+
| { ok: false; status: 400 | 404; message: string }
|
|
34
|
+
|
|
35
|
+
/** The route prefix both encoders/decoders agree on. */
|
|
36
|
+
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
|
|
37
|
+
|
|
38
|
+
/** Build the route URL for one absolute file path (client + tests). */
|
|
39
|
+
export function encodeHtmlUrl(sessionId: string, path: string): string {
|
|
40
|
+
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
|
41
|
+
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decode a route pathname into the session + absolute file path. Rejects
|
|
46
|
+
* a wrong prefix (404), an empty or double-slash path, malformed percent
|
|
47
|
+
* encoding, and a missing sessionId or file path (400). The caller still
|
|
48
|
+
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
|
|
49
|
+
* decoded `..` segment resolves outside the cwd and is refused there.
|
|
50
|
+
*/
|
|
51
|
+
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
|
|
52
|
+
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
|
|
53
|
+
return { ok: false, status: 404, message: 'not an html route' }
|
|
54
|
+
}
|
|
55
|
+
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
|
|
56
|
+
if (rest === '' || rest.includes('//')) {
|
|
57
|
+
return { ok: false, status: 400, message: 'invalid html route path' }
|
|
58
|
+
}
|
|
59
|
+
let segments: string[]
|
|
60
|
+
try {
|
|
61
|
+
segments = rest.split('/').map(segment => decodeURIComponent(segment))
|
|
62
|
+
} catch {
|
|
63
|
+
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
|
64
|
+
}
|
|
65
|
+
const [sessionId, ...pathSegments] = segments
|
|
66
|
+
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
|
|
67
|
+
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, ref: { sessionId, path: `/${pathSegments.join('/')}` } }
|
|
70
|
+
}
|