dsh-git-ui 0.0.1 → 0.0.2

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.
@@ -215,6 +215,9 @@ const globalCss = [
215
215
  '.dsh-git-ui__row:hover { background: var(--dsw-alias-interactive-bg-hover); }',
216
216
  '.dsh-git-ui__refresh:hover { color: var(--dsw-alias-state-business-primary); }',
217
217
  '.dsh-git-ui__refresh:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }',
218
+ // Git center dialog: widen the platform Modal card (headless mode).
219
+ '.dsh-git-ui__center { width: min(880px, 100vw); max-height: min(720px, calc(100vh - 48px)); }',
220
+ '.dsh-git-ui__commit-input:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: -2px; }',
218
221
  ].join('\n')
219
222
 
220
223
  /** Ensure the global interaction styles exist (idempotent; browser only). */
@@ -227,3 +230,153 @@ export function ensureGlobalCss(): void {
227
230
  tag.textContent = globalCss
228
231
  document.head.appendChild(tag)
229
232
  }
233
+
234
+ // ── Git center (management panel) styles ──────────────────────────────────
235
+
236
+ export const centerShell: CSSProperties = {
237
+ display: 'flex',
238
+ flexDirection: 'column',
239
+ height: '100%',
240
+ maxHeight: 'min(720px, calc(100vh - 48px))',
241
+ minHeight: 420,
242
+ fontSize: 13,
243
+ lineHeight: '20px',
244
+ color: 'var(--dsw-alias-label-primary)',
245
+ }
246
+
247
+ export const centerHeader: CSSProperties = {
248
+ display: 'flex',
249
+ alignItems: 'center',
250
+ gap: 10,
251
+ padding: '18px 20px 12px',
252
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
253
+ flex: 'none',
254
+ }
255
+
256
+ export const centerTitle: CSSProperties = {
257
+ margin: 0,
258
+ fontSize: 16,
259
+ lineHeight: '24px',
260
+ fontWeight: 500,
261
+ flex: 1,
262
+ overflow: 'hidden',
263
+ textOverflow: 'ellipsis',
264
+ whiteSpace: 'nowrap',
265
+ color: 'var(--dsw-alias-label-primary)',
266
+ }
267
+
268
+ export const centerBody: CSSProperties = {
269
+ flex: 1,
270
+ overflowY: 'auto',
271
+ padding: '10px 20px',
272
+ display: 'flex',
273
+ flexDirection: 'column',
274
+ gap: 10,
275
+ }
276
+
277
+ export const groupTitle: CSSProperties = {
278
+ margin: '8px 0 4px',
279
+ fontSize: 11,
280
+ fontWeight: 600,
281
+ color: 'var(--dsw-alias-label-secondary)',
282
+ textTransform: 'uppercase',
283
+ letterSpacing: 0.4,
284
+ }
285
+
286
+ export const centerRow: CSSProperties = {
287
+ display: 'flex',
288
+ alignItems: 'center',
289
+ gap: 8,
290
+ padding: '2px 8px',
291
+ borderRadius: 8,
292
+ }
293
+
294
+ export const changeCheckbox: CSSProperties = {
295
+ flex: 'none',
296
+ accentColor: 'var(--dsw-alias-state-business-primary)',
297
+ }
298
+
299
+ export const changePathText: CSSProperties = {
300
+ overflow: 'hidden',
301
+ textOverflow: 'ellipsis',
302
+ whiteSpace: 'nowrap',
303
+ flex: 1,
304
+ color: 'var(--dsw-alias-label-primary)',
305
+ fontSize: 13,
306
+ lineHeight: '20px',
307
+ }
308
+
309
+ export const toolRow: CSSProperties = {
310
+ display: 'flex',
311
+ alignItems: 'center',
312
+ gap: 8,
313
+ flexWrap: 'wrap',
314
+ padding: '6px 0 2px',
315
+ }
316
+
317
+ export const commitBox: CSSProperties = {
318
+ borderTop: '1px solid var(--dsw-alias-border-l1)',
319
+ padding: '12px 20px 16px',
320
+ display: 'flex',
321
+ flexDirection: 'column',
322
+ gap: 8,
323
+ flex: 'none',
324
+ background: 'var(--dsw-alias-bg-layer-1)',
325
+ }
326
+
327
+ export const commitInput: CSSProperties = {
328
+ width: '100%',
329
+ boxSizing: 'border-box',
330
+ minHeight: 64,
331
+ resize: 'vertical',
332
+ border: '1px solid var(--dsw-alias-border-l1)',
333
+ borderRadius: 8,
334
+ padding: '6px 10px',
335
+ fontSize: 14,
336
+ lineHeight: '22px',
337
+ fontFamily: 'inherit',
338
+ background: 'var(--dsw-alias-bg-layer-2)',
339
+ color: 'var(--dsw-alias-label-primary)',
340
+ }
341
+
342
+ export const commitFooter: CSSProperties = {
343
+ display: 'flex',
344
+ alignItems: 'center',
345
+ justifyContent: 'space-between',
346
+ gap: 8,
347
+ }
348
+
349
+ export const commitHint: CSSProperties = {
350
+ fontSize: 12,
351
+ lineHeight: '18px',
352
+ color: 'var(--dsw-alias-label-tertiary)',
353
+ }
354
+
355
+ /** Inline error banner (panel-level; stays until dismissed or next action). */
356
+ export const feedbackError: CSSProperties = {
357
+ display: 'flex',
358
+ alignItems: 'flex-start',
359
+ gap: 8,
360
+ padding: '8px 10px',
361
+ borderRadius: 8,
362
+ fontSize: 12,
363
+ lineHeight: '18px',
364
+ background: 'var(--dsw-alias-state-error-secondary)',
365
+ color: 'var(--dsw-alias-state-error-primary)',
366
+ whiteSpace: 'pre-wrap',
367
+ wordBreak: 'break-word',
368
+ maxHeight: 120,
369
+ overflowY: 'auto',
370
+ }
371
+
372
+ export const feedbackClose: CSSProperties = {
373
+ flex: 'none',
374
+ border: 0,
375
+ background: 'transparent',
376
+ padding: '0 2px',
377
+ cursor: 'pointer',
378
+ color: 'inherit',
379
+ fontSize: 13,
380
+ lineHeight: '18px',
381
+ opacity: 0.7,
382
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Framework-free git management operation runner.
3
+ *
4
+ * Same layering as `core.ts`: every dependency is injected structurally, the
5
+ * whole flow is testable against real temporary git repositories without a
6
+ * cordis runtime, and `GitStatusService` only adapts host services into the
7
+ * `SnapshotDeps` face.
8
+ *
9
+ * Security model: the browser only ever sends a `sessionId` plus
10
+ * repository-relative paths (as listed in a snapshot's `changes`). Paths are
11
+ * validated against the work-tree root (absolute paths and `..` escapes are
12
+ * rejected) and every git invocation uses `--` so a path can never be
13
+ * interpreted as an option. Commands run through the same subprocess adapter
14
+ * as the read-only snapshot flow — no shell is involved.
15
+ */
16
+ import { resolve, sep } from 'node:path'
17
+ import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
+ import type { GitAction, GitActionResult, GitActionRequest } from './types.ts'
19
+
20
+ /** Build the command sequence for one action, validating every path against the root. */
21
+ function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
22
+ switch (action.kind) {
23
+ case 'stage':
24
+ return withPaths(['git', 'add', '--'], action.paths, root)
25
+ case 'stage-all':
26
+ return { argv: [['git', 'add', '-A']] }
27
+ case 'unstage':
28
+ return withPaths(['git', 'restore', '--staged', '--'], action.paths, root)
29
+ case 'unstage-all':
30
+ return { argv: [['git', 'restore', '--staged', '--', '.']] }
31
+ case 'discard':
32
+ return withPaths(['git', 'restore', '--'], action.paths, root)
33
+ case 'discard-all':
34
+ // Reset the index to HEAD first, then the work tree to the index — the
35
+ // IDE-style "roll back everything tracked" semantics.
36
+ return { argv: [['git', 'restore', '--staged', '--', '.'], ['git', 'restore', '--', '.']] }
37
+ case 'commit': {
38
+ // Message emptiness is validated by runAction (git-error), not here.
39
+ const message = action.message.trim()
40
+ if (action.paths === undefined || action.paths.length === 0) {
41
+ return { argv: [['git', 'commit', '-m', message]] }
42
+ }
43
+ // git commit -- <paths> stages those paths from the work tree and
44
+ // commits only them (index state of other paths is ignored) — the
45
+ // IDE-style "commit selected files" semantics.
46
+ return withPaths(['git', 'commit', '-m', message, '--'], action.paths, root)
47
+ }
48
+ }
49
+ }
50
+
51
+ /** Append validated repo-relative paths behind `--`. */
52
+ function withPaths(prefix: readonly string[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
53
+ if (paths.length === 0) return { error: 'no paths given' }
54
+ for (const path of paths) {
55
+ if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
56
+ }
57
+ return { argv: [[...prefix, ...paths]] }
58
+ }
59
+
60
+ /**
61
+ * A path is safe when it is repo-relative and stays inside the work tree:
62
+ * reject absolute paths, drive letters / backslashes, and `..` escapes
63
+ * (checked via path resolution against the realpath'd root).
64
+ */
65
+ export function isSafePath(path: string, root: string): boolean {
66
+ if (path === '') return false
67
+ if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) return false
68
+ const resolved = resolve(root, path)
69
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`
70
+ return resolved === root || resolved.startsWith(prefix)
71
+ }
72
+
73
+ /** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
74
+ function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
75
+ if (failure.code === 'git-unavailable') {
76
+ return { ok: false, error: { code: 'git-error', message: failure.detail } }
77
+ }
78
+ return { ok: false, error: failure }
79
+ }
80
+
81
+ /**
82
+ * Execute one management action against the session's repository and return
83
+ * the refreshed snapshot on success (the caller re-renders from it, so the
84
+ * UI never waits for the next poll).
85
+ */
86
+ export async function runAction(
87
+ deps: SnapshotDeps,
88
+ config: GitStatusConfig,
89
+ request: GitActionRequest,
90
+ ): Promise<GitActionResult> {
91
+ const workspace = await resolveWorkspace(deps, request.sessionId)
92
+ if (!workspace.ok) return operationError(workspace.error)
93
+ const root = workspace.root
94
+
95
+ if (request.action.kind === 'commit' && request.action.message.trim() === '') {
96
+ return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
97
+ }
98
+
99
+ const built = buildArgv(request.action, root)
100
+ if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
101
+
102
+ // Run the command sequence; a failure stops the rest (the first commands
103
+ // may already have taken effect — they are all idempotent restores).
104
+ let lastStdout = ''
105
+ for (const argv of built.argv) {
106
+ const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
107
+ if ('failure' in outcome) return operationError(outcome.failure)
108
+ if (outcome.run.timedOut) return { ok: false, error: { code: 'timeout' } }
109
+ if (outcome.run.exitCode !== 0) {
110
+ // git writes user-facing failures to stderr OR stdout (e.g. a clean
111
+ // repo's `git commit` reports "nothing to commit" on stdout).
112
+ const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
113
+ return {
114
+ ok: false,
115
+ error: {
116
+ code: 'git-error',
117
+ message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
118
+ },
119
+ }
120
+ }
121
+ lastStdout = outcome.run.stdout.trim()
122
+ }
123
+
124
+ const snapshot = await snapshotForSession(deps, config, request.sessionId)
125
+ if (!snapshot.ok) return operationError(snapshot.error)
126
+ return { ok: true, snapshot: snapshot.value, ...(lastStdout === '' ? {} : { output: lastStdout }) }
127
+ }
package/src/host/core.ts CHANGED
@@ -89,7 +89,7 @@ function runFailure(result: { readonly timedOut: boolean }, detail: string): Ext
89
89
  }
90
90
 
91
91
  /** Run one command, mapping a spawn-level failure to a snapshot failure. */
92
- async function runCommand(
92
+ export async function runCommand(
93
93
  runner: GitRunner,
94
94
  argv: readonly string[],
95
95
  cwd: string,
@@ -104,20 +104,18 @@ async function runCommand(
104
104
  }
105
105
 
106
106
  /**
107
- * Build one frozen GitSnapshot for a session working directory.
108
- * Command sequence (all read-only; every command after the first runs with
109
- * the repository root as cwd):
110
- * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
111
- * 2. `git branch --show-current` — null when detached
112
- * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
113
- * 4. `git status --porcelain=v1 -z --branch`
114
- * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
107
+ * Resolve a session's repository workspace: cwd (live or persisted), the
108
+ * realpath'd directory, and the git work-tree root via `rev-parse
109
+ * --show-toplevel`. Shared by the snapshot flow and the operation runner.
115
110
  */
116
- export async function snapshotForSession(
111
+ export type WorkspaceResolution =
112
+ | { readonly ok: true; readonly cwd: string; readonly root: string }
113
+ | { readonly ok: false; readonly error: Extract<GitSnapshotResult, { ok: false }>['error'] }
114
+
115
+ export async function resolveWorkspace(
117
116
  deps: SnapshotDeps,
118
- config: GitStatusConfig,
119
117
  sessionId: string,
120
- ): Promise<GitSnapshotResult> {
118
+ ): Promise<WorkspaceResolution> {
121
119
  const resolved = await resolveCwd(deps.sessions, sessionId)
122
120
  if (!resolved.ok) return { ok: false, error: resolved.error }
123
121
 
@@ -149,6 +147,27 @@ export async function snapshotForSession(
149
147
  }
150
148
  const root = toplevel.run.stdout.trim()
151
149
  if (root === '') return { ok: false, error: { code: 'not-a-git-repo' } }
150
+ return { ok: true, cwd: realCwd, root }
151
+ }
152
+
153
+ /**
154
+ * Build one frozen GitSnapshot for a session working directory.
155
+ * Command sequence (all read-only; every command after the first runs with
156
+ * the repository root as cwd):
157
+ * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
158
+ * 2. `git branch --show-current` — null when detached
159
+ * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
160
+ * 4. `git status --porcelain=v1 -z --branch`
161
+ * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
162
+ */
163
+ export async function snapshotForSession(
164
+ deps: SnapshotDeps,
165
+ config: GitStatusConfig,
166
+ sessionId: string,
167
+ ): Promise<GitSnapshotResult> {
168
+ const workspace = await resolveWorkspace(deps, sessionId)
169
+ if (!workspace.ok) return { ok: false, error: workspace.error }
170
+ const root = workspace.root
152
171
 
153
172
  const branchRun = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
154
173
  if ('failure' in branchRun) return { ok: false, error: branchRun.failure }
package/src/host/index.ts CHANGED
@@ -1,22 +1,25 @@
1
1
  /**
2
2
  * dsh-git-ui host half: the `gitInfo` Remote service.
3
3
  *
4
- * Cordis shell only — every behavior lives in `core.ts` behind injected
5
- * structural faces, so tests never need a cordis runtime. The class is a
6
- * plugin in its own right (class form), mounted by the bundle patch row with
7
- * the package name; the gateway exposes `gitInfo/snapshot` through SRC
8
- * discovery (`typertRemote` binding + `@Remote` marker).
4
+ * Cordis shell only — every behavior lives in `core.ts`/`actions.ts` behind
5
+ * injected structural faces, so tests never need a cordis runtime. The class
6
+ * is a plugin in its own right (class form), mounted by the bundle patch row
7
+ * with the package name; the gateway exposes `gitInfo/snapshot` and
8
+ * `gitInfo/run` through SRC discovery (`typertRemote` binding + `@Remote`
9
+ * marker).
9
10
  */
10
11
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
11
12
  import type { Context } from '@deepseek-ai/cordis'
12
13
  import { realpath, stat } from 'node:fs/promises'
13
14
  import { createGitRunner, type SubprocessLike } from './git.ts'
14
- import { normalizeConfig, snapshotForSession, type GitStatusConfig } from './core.ts'
15
- import type { GitSnapshotRequest, GitSnapshotResult } from './types.ts'
15
+ import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
16
+ import { runAction } from './actions.ts'
17
+ import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
16
18
 
17
- export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange } from './types.ts'
19
+ export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts'
18
20
  export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
19
21
  export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
22
+ export { isSafePath, runAction } from './actions.ts'
20
23
 
21
24
  /** Structural face of a live session header. */
22
25
  interface SessionLike {
@@ -33,7 +36,7 @@ interface SessionPersistenceLike {
33
36
  inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
34
37
  }
35
38
 
36
- /** The `gitInfo` service: one `snapshot` Remote endpoint. */
39
+ /** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
37
40
  export class GitStatusService extends TypertRemoteService {
38
41
  static inject = ['subprocess', 'sessions', 'sessionPersistence']
39
42
 
@@ -44,17 +47,17 @@ export class GitStatusService extends TypertRemoteService {
44
47
  this.config = normalizeConfig(config)
45
48
  }
46
49
 
47
- @Remote('snapshot')
48
- async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
50
+ /** Adapter face shared by both endpoints (injected services + runner). */
51
+ private deps(signal?: AbortSignal): { readonly deps: SnapshotDeps } | { readonly failure: { readonly code: 'git-unavailable'; readonly detail: string } } {
49
52
  const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
50
53
  if (subprocess === undefined) {
51
- return { ok: false, error: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
54
+ return { failure: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
52
55
  }
53
56
  const sessions = this.ctx.get('sessions') as SessionsLike | undefined
54
57
  const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
55
58
  const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
56
- return snapshotForSession(
57
- {
59
+ return {
60
+ deps: {
58
61
  run: runner,
59
62
  fs: { realpath, stat },
60
63
  sessions: {
@@ -71,9 +74,23 @@ export class GitStatusService extends TypertRemoteService {
71
74
  },
72
75
  signal,
73
76
  },
74
- this.config,
75
- request.sessionId,
76
- )
77
+ }
78
+ }
79
+
80
+ @Remote('snapshot')
81
+ async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
82
+ const adapted = this.deps(signal)
83
+ if ('failure' in adapted) return { ok: false, error: adapted.failure }
84
+ return snapshotForSession(adapted.deps, this.config, request.sessionId)
85
+ }
86
+
87
+ @Remote('run')
88
+ async run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult> {
89
+ const adapted = this.deps(signal)
90
+ if ('failure' in adapted) {
91
+ return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
92
+ }
93
+ return runAction(adapted.deps, this.config, request)
77
94
  }
78
95
  }
79
96
 
package/src/host/types.ts CHANGED
@@ -72,3 +72,42 @@ export type GitChangeStatus =
72
72
  | 'untracked'
73
73
  | 'conflicted'
74
74
  | 'typechange'
75
+
76
+ /**
77
+ * One git management operation addressed by the `run` endpoint. Paths are
78
+ * always repository-relative (as listed in a snapshot's `changes`), never
79
+ * absolute — the host validates them against the work tree.
80
+ */
81
+ export type GitAction =
82
+ | { readonly kind: 'stage'; readonly paths: readonly string[] }
83
+ | { readonly kind: 'stage-all' }
84
+ | { readonly kind: 'unstage'; readonly paths: readonly string[] }
85
+ | { readonly kind: 'unstage-all' }
86
+ | { readonly kind: 'discard'; readonly paths: readonly string[] }
87
+ | { readonly kind: 'discard-all' }
88
+ | {
89
+ readonly kind: 'commit'
90
+ readonly message: string
91
+ /** Commit only these paths (git commit -- <paths> semantics); absent or
92
+ * empty commits everything already staged. */
93
+ readonly paths?: readonly string[]
94
+ }
95
+
96
+ export type GitOperationErrorCode =
97
+ | 'session-not-found'
98
+ | 'cwd-unavailable'
99
+ | 'path-not-found'
100
+ | 'not-a-git-repo'
101
+ | 'invalid-path'
102
+ | 'git-error'
103
+ | 'timeout'
104
+
105
+ export type GitActionResult =
106
+ | { readonly ok: true; readonly snapshot: GitSnapshot; readonly output?: string }
107
+ | { readonly ok: false; readonly error: { readonly code: GitOperationErrorCode; readonly message?: string } }
108
+
109
+ /** Wire request of the `run` endpoint. */
110
+ export interface GitActionRequest {
111
+ readonly sessionId: string
112
+ readonly action: GitAction
113
+ }