@young1lin/dsh-ui-gitworkbench 0.1.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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Per-project and global drawer styling: a background image and custom CSS.
3
+ *
4
+ * These live on the host rather than in the browser for two reasons. A project
5
+ * setting belongs to the project, so it must survive a different browser or a
6
+ * cleared origin; and a background image is far larger than a localStorage
7
+ * origin quota is willing to hold.
8
+ *
9
+ * The file is a durable boundary: everything read back is validated here, and
10
+ * anything unrecognized is dropped rather than propagated. The image in
11
+ * particular is interpolated into a CSS `url()` by the client, so it is held to
12
+ * a base64 `data:` URL with no character that could close the function and
13
+ * continue the stylesheet.
14
+ */
15
+ import { join } from 'node:path'
16
+
17
+ /** One scope's styling. Absent fields are impossible: every entry is complete. */
18
+ export interface StyleEntry {
19
+ /** Custom CSS injected into the page verbatim; empty for none. */
20
+ readonly css: string
21
+ /** Background image as a base64 `data:` URL; empty for none. */
22
+ readonly image: string
23
+ /** Background blur radius in px. */
24
+ readonly blur: number
25
+ /** How much of the palette's own surface colour covers the image, in percent. */
26
+ readonly veil: number
27
+ }
28
+
29
+ export interface StyleFile {
30
+ readonly v: 1
31
+ readonly global: StyleEntry
32
+ /** Keyed by repository root, forward-slashed. */
33
+ readonly projects: Record<string, StyleEntry>
34
+ }
35
+
36
+ /** No image, no CSS, and the defaults the sliders open on. */
37
+ export const DEFAULT_STYLE: StyleEntry = { css: '', image: '', blur: 18, veil: 78 }
38
+
39
+ /** Largest accepted image data URL. A 2560px JPEG lands far below this; the cap
40
+ * exists so a hand-edited file cannot make every drawer open drag megabytes. */
41
+ export const STYLE_IMAGE_MAX = 3_000_000
42
+
43
+ /** Largest accepted custom stylesheet. */
44
+ export const STYLE_CSS_MAX = 200_000
45
+
46
+ /** Largest accepted blur radius, in px. */
47
+ export const STYLE_BLUR_MAX = 60
48
+
49
+ /**
50
+ * Images this plugin will render.
51
+ *
52
+ * Deliberately narrow: the client interpolates the value into `url("…")`, and a
53
+ * base64 alphabet cannot contain a quote, a parenthesis, a backslash or a
54
+ * semicolon, so a stored value can never close the function and append rules of
55
+ * its own. It is also exactly what a canvas `toDataURL` produces, so nothing a
56
+ * user can select through the picker is rejected.
57
+ */
58
+ const IMAGE_PATTERN = /^data:image\/(?:png|jpeg|webp|gif|avif);base64,[A-Za-z0-9+/]+={0,2}$/
59
+
60
+ /**
61
+ * @param home - the user's home directory.
62
+ * @returns the style file's path, forward-slashed.
63
+ */
64
+ export function stylePath(home: string): string {
65
+ return join(home, '.dsh', 'gitworkbench-style.json').replace(/\\/g, '/')
66
+ }
67
+
68
+ /**
69
+ * @param value - a number from a file or an RPC argument.
70
+ * @param min - lower bound.
71
+ * @param max - upper bound.
72
+ * @param fallback - used when the value is not a finite number.
73
+ * @returns the value clamped into range.
74
+ */
75
+ function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
76
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
77
+ return Math.min(Math.max(value, min), max)
78
+ }
79
+
80
+ /**
81
+ * Narrow an arbitrary value to a complete, in-range style entry.
82
+ * @param value - parsed JSON or an RPC argument.
83
+ * @returns a valid entry; every rejected field falls back to its default.
84
+ */
85
+ export function sanitizeEntry(value: unknown): StyleEntry {
86
+ if (typeof value !== 'object' || value === null) return DEFAULT_STYLE
87
+ const record = value as Record<string, unknown>
88
+ const css = typeof record['css'] === 'string' && record['css'].length <= STYLE_CSS_MAX ? record['css'] : ''
89
+ const raw = record['image']
90
+ const image = typeof raw === 'string' && raw.length <= STYLE_IMAGE_MAX && IMAGE_PATTERN.test(raw) ? raw : ''
91
+ return {
92
+ css,
93
+ image,
94
+ blur: clampNumber(record['blur'], 0, STYLE_BLUR_MAX, DEFAULT_STYLE.blur),
95
+ veil: clampNumber(record['veil'], 0, 100, DEFAULT_STYLE.veil),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @returns a style file with nothing configured.
101
+ */
102
+ export function emptyStyleFile(): StyleFile {
103
+ return { v: 1, global: DEFAULT_STYLE, projects: {} }
104
+ }
105
+
106
+ /**
107
+ * @param entry - a style entry.
108
+ * @returns whether it configures anything at all; an entry that does not is not
109
+ * worth storing, and storing it would shadow the global scope with nothing.
110
+ */
111
+ export function isBlankEntry(entry: StyleEntry): boolean {
112
+ return entry.css.length === 0 && entry.image.length === 0
113
+ }
114
+
115
+ /**
116
+ * Parse the style file, dropping anything malformed.
117
+ * @param raw - the file's text.
118
+ * @returns a valid style file; a corrupt one reads as empty rather than failing.
119
+ */
120
+ export function parseStyle(raw: string): StyleFile {
121
+ try {
122
+ const parsed = JSON.parse(raw) as { v?: unknown; global?: unknown; projects?: unknown }
123
+ if (parsed?.v !== 1) return emptyStyleFile()
124
+ const projects: Record<string, StyleEntry> = {}
125
+ if (typeof parsed.projects === 'object' && parsed.projects !== null) {
126
+ for (const [root, entry] of Object.entries(parsed.projects as Record<string, unknown>)) {
127
+ if (root.length > 0) projects[root] = sanitizeEntry(entry)
128
+ }
129
+ }
130
+ return { v: 1, global: sanitizeEntry(parsed.global), projects }
131
+ } catch {
132
+ // A half-written or hand-edited file must not take the drawer down with it.
133
+ return emptyStyleFile()
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @param readText - reads a file's text.
139
+ * @param path - the style file's path.
140
+ * @returns the stored styles, or an empty file when absent or unreadable.
141
+ */
142
+ export async function loadStyle(readText: (path: string) => Promise<string>, path: string): Promise<StyleFile> {
143
+ try { return parseStyle(await readText(path)) } catch { return emptyStyleFile() }
144
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ambient shims for the CLIENT half, so `tsc` can check it WITHOUT
3
+ * `@deepseek-ai/dsh-client-runtime` and `@deepseek-ai/dsh-client-ui-slots`
4
+ * installed — they are peer dependencies resolved from the web profile's module
5
+ * farm at runtime, and this repository never installs them.
6
+ *
7
+ * WHAT THIS IS FOR: catching mistakes in THIS plugin's own code — a stale
8
+ * identifier after a refactor, a prop that no longer exists, a missing field, a
9
+ * type that does not line up. Before it existed the client half was never
10
+ * typechecked at all: `tsconfig.json` includes only the host entry, and tsdown
11
+ * builds with `dts: false` while rolldown does no checking.
12
+ *
13
+ * WHAT THIS IS NOT: a copy of the harness's types. Everything at the dsh
14
+ * boundary below is deliberately loose, so it can confirm a call is shaped
15
+ * roughly right but never that it matches the real signature. The authority is
16
+ * the harness monorepo, and a green check here does NOT prove this plugin still
17
+ * fits the host it mounts into — only running it does. Widen a declaration when
18
+ * it blocks correct code; never narrow one to encode a guess.
19
+ */
20
+
21
+ declare module '*.module.css' {
22
+ /** CSS-module class map; the build inlines the stylesheet and exports this. */
23
+ const classes: Record<string, string>
24
+ export default classes
25
+ }
26
+
27
+ /** Host frozen table supplies this at runtime; only `createPortal` is used here. */
28
+ declare module 'react-dom' {
29
+ import type { ReactNode } from 'react'
30
+ export function createPortal(children: ReactNode, container: Element | DocumentFragment): ReactNode
31
+ }
32
+
33
+ /** Bare specifier imported for load ordering only; it contributes no types here. */
34
+ declare module '@deepseek-ai/dsh-client-runtime' {}
35
+
36
+ declare module '@deepseek-ai/dsh-client-runtime/client' {
37
+ /** One slot contribution, as `ctx.slots.register` accepts it. */
38
+ export interface SlotRegistration {
39
+ /** Slot to contribute to. */
40
+ readonly name: string
41
+ /** Identity of this contribution within the slot. */
42
+ readonly id: string
43
+ /** Sort key among the slot's entries. */
44
+ readonly order?: number
45
+ /**
46
+ * Locale namespace. Declaring it makes the framework synthesize the `t` prop;
47
+ * this plugin's namespace is outside dsh's typed map, so the component
48
+ * declares `t` itself rather than receiving a checked type here.
49
+ */
50
+ readonly locale?: string
51
+ /** Business callbacks handed to the component as props. */
52
+ readonly inject?: () => Record<string, unknown>
53
+ }
54
+
55
+ /** The client-side plugin context an `apply` receives. */
56
+ export interface ClientContext {
57
+ /**
58
+ * Register a contribution as a disposable effect.
59
+ * @param apply - performs the registration and returns its disposer.
60
+ * @param label - diagnostic name for the effect.
61
+ * @returns a disposer for the effect itself.
62
+ */
63
+ effect(apply: () => (() => void) | void, label?: string): () => void
64
+ slots: {
65
+ /**
66
+ * Run `apply` once the named slot exists.
67
+ * @param name - slot being contributed to.
68
+ * @param apply - performs the registration.
69
+ */
70
+ inject(name: string, apply: () => void): void
71
+ /**
72
+ * Contribute one component to a slot.
73
+ * @param registration - slot, identity, ordering, locale and injected props.
74
+ * @param component - the component rendered for this entry.
75
+ * @returns the disposer.
76
+ */
77
+ register(registration: SlotRegistration, component: unknown): () => void
78
+ }
79
+ }
80
+ }
81
+
82
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
83
+ /**
84
+ * Props every slot component receives from the runtime.
85
+ *
86
+ * The real type is keyed on the slot name; this approximation carries only the
87
+ * members this plugin reads, which is what lets its own usage be checked.
88
+ */
89
+ export interface PropsRuntime<N extends string = string> {
90
+ /** Session this header belongs to. */
91
+ readonly sessionId: string
92
+ /**
93
+ * Subscribe to a slice of the sessions store. The store's type lives in the
94
+ * harness, so the selector's parameter is annotated at each call site.
95
+ * @param selector - picks the slice to subscribe to.
96
+ * @returns the selected slice.
97
+ */
98
+ readonly useSessions: <T>(selector: (state: never) => T) => T
99
+ }
100
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Ambient shims so `tsc` can transpile the host half WITHOUT the @deepseek-ai
3
+ * packages installed locally — they resolve from the web profile's module farm
4
+ * at runtime. Types here are intentionally loose (strict is off); they exist
5
+ * only to let the decorator transform and emit. The real types live in the
6
+ * harness monorepo and govern behaviour once loaded in-profile.
7
+ */
8
+
9
+ declare module '@deepseek-ai/cordis' {
10
+ export interface Context {
11
+ tools: {
12
+ /** ToolRuntime.register — returns the dispose callback; loose here, see file header. */
13
+ register(definition: unknown): () => void
14
+ }
15
+ /**
16
+ * SystemPrompt registry. Only `context()` is mirrored — the dynamic
17
+ * per-assembly contribution, whose `text` provider is SYNCHRONOUS (see the
18
+ * real `PromptContext` in packages/core/system-prompt/src/index.ts). The
19
+ * `agent` field on the assemble context is merged in by `dsh-agent`.
20
+ */
21
+ systemPrompt: {
22
+ context(input: {
23
+ name: string
24
+ order: number
25
+ text: (context: { agent?: { session: { id: string } } }) => string
26
+ }): () => void
27
+ }
28
+ /** Mount a child scope once the named services are available; loose here, see file header. */
29
+ inject(services: readonly string[], apply: (scope: Context) => void): void
30
+ subprocess: {
31
+ spawn(spec: {
32
+ argv: readonly string[]
33
+ cwd: string
34
+ stdio: { stdin: unknown; stdout: 'pipe'; stderr: 'pipe' }
35
+ graceMs: number
36
+ signal?: AbortSignal
37
+ /** Merged onto the implementation's scrubbed base environment. */
38
+ env?: Record<string, string> | undefined
39
+ }): {
40
+ stdout: import('node:stream').Readable | undefined
41
+ stderr: import('node:stream').Readable | undefined
42
+ done: Promise<{ exitCode: number | null; signal: string | null }>
43
+ }
44
+ }
45
+ }
46
+ export class Service<T = never> {
47
+ constructor(ctx: Context, name: string)
48
+ readonly ctx: Context
49
+ }
50
+ }
51
+
52
+ declare module '@deepseek-ai/dsh-typert-protocol' {
53
+ import type { Service, Context } from '@deepseek-ai/cordis'
54
+ type Stage3MethodDecorator = (value: Function, context: ClassMethodDecoratorContext) => void
55
+ export class TypertRemoteService<T = never> extends Service<T> {
56
+ protected constructor(ctx: Context, serviceKey: string, options?: { namespace?: string })
57
+ readonly typertRemote: unknown
58
+ }
59
+ export function Remote(): Stage3MethodDecorator
60
+ export function Remote(exportName: string): Stage3MethodDecorator
61
+ }
62
+
63
+ declare module '@deepseek-ai/dsh-tools' {
64
+ /**
65
+ * Loose mirror of the real ToolRunContext (packages/core/tools/src/index.ts):
66
+ * only the agent → session → header.cwd path the worktree tools read. The
67
+ * real type carries far more (signal, deferContext, events, ...); the shim
68
+ * keeps tsc happy while the runtime object provides these fields.
69
+ */
70
+ export interface ToolRunContext {
71
+ agent?: { id: string; session: { id: string; header: { cwd?: string } } }
72
+ /** Turn-scoped cancellation the executor always supplies; forward it to every subprocess. */
73
+ signal: AbortSignal
74
+ }
75
+ /** Identity-typed defineTool — it only wraps the definition object at runtime. */
76
+ export function defineTool(tool: Record<string, unknown>): unknown
77
+ }
@@ -0,0 +1,142 @@
1
+ // src/worktree.ts — Task 1 delivers only the binding storage; later tasks append.
2
+ import { join } from 'node:path'
3
+ import { saveJsonAtomic } from './atomic-json.js'
4
+
5
+ export interface WorktreeBinding {
6
+ readonly repoRoot: string
7
+ readonly worktreePath: string
8
+ readonly name: string
9
+ readonly enteredAt: string
10
+ /**
11
+ * Commit the worktree's branch started from. Optional on purpose: bindings
12
+ * written before this field existed stay valid, and a reuse path can fail to
13
+ * recover a historical branch point. A consumer without it falls back to
14
+ * diffing against the worktree's own HEAD.
15
+ */
16
+ readonly baseCommit?: string
17
+ }
18
+
19
+ export interface BindingsFile { readonly v: 1; readonly bindings: Record<string, WorktreeBinding> }
20
+
21
+ const BINDINGS_FIELDS = ['repoRoot', 'worktreePath', 'name', 'enteredAt'] as const
22
+
23
+ function emptyFile(): BindingsFile { return { v: 1, bindings: {} } }
24
+
25
+ function isBinding(value: unknown): value is WorktreeBinding {
26
+ if (typeof value !== 'object' || value === null) return false
27
+ const record = value as Record<string, unknown>
28
+ if (!BINDINGS_FIELDS.every(field => typeof record[field] === 'string' && (record[field] as string).length > 0)) return false
29
+ // Absent is normal; present-but-malformed is corruption, and dropping the
30
+ // whole record beats trusting half of it.
31
+ const base = record['baseCommit']
32
+ return base === undefined || (typeof base === 'string' && base.length > 0)
33
+ }
34
+
35
+ export function bindingsPath(home: string): string {
36
+ return join(home, '.dsh', 'gitworkbench-worktree-bindings.json').replace(/\\/g, '/')
37
+ }
38
+
39
+ export function parseBindings(raw: string): BindingsFile {
40
+ try {
41
+ const parsed = JSON.parse(raw) as { v?: unknown; bindings?: unknown }
42
+ if (parsed?.v !== 1 || typeof parsed.bindings !== 'object' || parsed.bindings === null) return emptyFile()
43
+ const out = emptyFile()
44
+ for (const [sessionId, value] of Object.entries(parsed.bindings as Record<string, unknown>)) {
45
+ if (sessionId.length > 0 && isBinding(value)) out.bindings[sessionId] = value
46
+ }
47
+ return out
48
+ } catch {
49
+ return emptyFile()
50
+ }
51
+ }
52
+
53
+ export async function loadBindings(readText: (path: string) => Promise<string>, path: string): Promise<BindingsFile> {
54
+ try { return parseBindings(await readText(path)) } catch { return emptyFile() }
55
+ }
56
+
57
+ export async function saveBindings(
58
+ ensureDir: (dir: string) => Promise<void>,
59
+ writeText: (path: string, text: string) => Promise<void>,
60
+ rename: (from: string, to: string) => Promise<void>,
61
+ path: string,
62
+ file: BindingsFile,
63
+ ): Promise<void> {
64
+ await saveJsonAtomic(ensureDir, writeText, rename, path, file)
65
+ }
66
+
67
+ // ---- Task 3: RPC return shapes (all JSON-safe; success writes no error key) ----
68
+
69
+ export interface WorktreeOpResult {
70
+ readonly ok: boolean
71
+ /** Present on success. */
72
+ readonly worktreePath?: string
73
+ /** Present on success of enter/exit. */
74
+ readonly branch?: string
75
+ /** Operational guidance for the model, present when meaningful. */
76
+ readonly hint?: string
77
+ /** Present on failure only. */
78
+ readonly error?: string
79
+ }
80
+
81
+ // ---- Task 2: worktree name/branch/path derivation + porcelain parsing ----
82
+
83
+ const NAME_PATTERN = /^[A-Za-z0-9._-]{1,40}$/
84
+
85
+ export interface WorktreeEntry { readonly path: string; readonly head: string; readonly branch: string }
86
+
87
+ export function sanitizeName(raw: string | undefined, rng: () => string): string {
88
+ if (raw !== undefined && NAME_PATTERN.test(raw) && raw !== '.' && raw !== '..') return raw
89
+ return `wt-${rng()}`
90
+ }
91
+
92
+ export function branchFor(name: string): string { return `wt/${name}` }
93
+
94
+ /** Longest ref name accepted — well past any real branch, short of a payload. */
95
+ const REF_MAX_LENGTH = 200
96
+ /** The character set git allows in a branch or tag name. */
97
+ const REF_CHARS = /^[A-Za-z0-9._/-]+$/
98
+
99
+ /**
100
+ * Decide whether a ref name from an untrusted caller may be passed to git.
101
+ *
102
+ * A ref arrives from the browser as free text and becomes a POSITIONAL argument,
103
+ * so three things are rejected before it gets there: a leading `-`, which git
104
+ * would read as an option rather than a ref; `..`, which is range syntax and
105
+ * would silently change what a comparison covers; and any character outside the
106
+ * set git accepts in a ref name.
107
+ * @param ref - candidate ref name.
108
+ * @returns true when the value is safe to pass to git as a ref.
109
+ */
110
+ export function isRefName(ref: string): boolean {
111
+ return typeof ref === 'string'
112
+ && ref.length > 0 && ref.length <= REF_MAX_LENGTH
113
+ && !ref.startsWith('-')
114
+ && !ref.includes('..')
115
+ && REF_CHARS.test(ref)
116
+ }
117
+
118
+ export function worktreeDir(repoRoot: string, name: string): string {
119
+ return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`
120
+ }
121
+
122
+ export function parseWorktreeList(porcelain: string): WorktreeEntry[] {
123
+ const out: WorktreeEntry[] = []
124
+ let path = ''
125
+ let head = ''
126
+ let branch = ''
127
+ const flush = (): void => {
128
+ if (path.length > 0 && head.length > 0 && branch.length > 0) {
129
+ out.push({ path, head, branch })
130
+ }
131
+ path = ''; head = ''; branch = ''
132
+ }
133
+ for (const line of porcelain.split('\n')) {
134
+ if (line.length === 0) { flush(); continue }
135
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
136
+ else if (line.startsWith('HEAD ')) head = line.slice('HEAD '.length)
137
+ else if (line.startsWith('branch refs/heads/')) branch = line.slice('branch refs/heads/'.length)
138
+ else if (line === 'detached') { path = ''; head = ''; branch = '' }
139
+ }
140
+ flush()
141
+ return out
142
+ }