dsh-git-ui 0.0.2 → 0.1.1

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.
Files changed (47) hide show
  1. package/README.md +55 -20
  2. package/README.zh.md +55 -21
  3. package/cordis.patch.yml +1 -1
  4. package/lib/client.js +36 -35
  5. package/lib/client.js.map +4 -4
  6. package/lib/contracts/host-endpoints.d.ts +27 -0
  7. package/lib/host/actions.d.ts +22 -2
  8. package/lib/host/core.d.ts +7 -1
  9. package/lib/host/index.d.ts +25 -15
  10. package/lib/host/index.js +419 -53
  11. package/lib/host/index.js.map +4 -4
  12. package/lib/host/parser.d.ts +37 -1
  13. package/lib/host/queries.d.ts +17 -0
  14. package/lib/host/types.d.ts +131 -1
  15. package/package.json +1 -1
  16. package/src/adapters/dsh/client-adapter.ts +120 -0
  17. package/src/adapters/dsh/types/cordis.d.ts +48 -0
  18. package/src/adapters/dsh/types/typert-protocol.d.ts +81 -0
  19. package/src/adapters/dsh/types/ui-primitives.d.ts +45 -0
  20. package/src/adapters/dsh/ui-primitives.ts +16 -0
  21. package/src/client/GitCenter.tsx +1414 -149
  22. package/src/client/GitPill.tsx +282 -67
  23. package/src/client/changes-diff.ts +63 -0
  24. package/src/client/controller.ts +34 -31
  25. package/src/client/error-text.ts +21 -0
  26. package/src/client/file-tree.ts +101 -0
  27. package/src/client/git-graph.ts +188 -0
  28. package/src/client/icons.tsx +292 -0
  29. package/src/client/index.ts +38 -134
  30. package/src/client/locales.ts +124 -0
  31. package/src/client/popup-close.ts +19 -0
  32. package/src/client/remote.ts +85 -3
  33. package/src/client/select-menu.tsx +113 -0
  34. package/src/client/side-by-side.ts +150 -0
  35. package/src/client/styles.ts +1375 -86
  36. package/src/client/time-format.ts +32 -0
  37. package/src/contracts/client-platform.ts +147 -0
  38. package/src/contracts/host-endpoints.ts +58 -0
  39. package/src/contracts/plugin-activation.ts +129 -0
  40. package/src/contracts/ui-context.tsx +28 -0
  41. package/src/contracts/ui-primitives.ts +48 -0
  42. package/src/host/actions.ts +66 -15
  43. package/src/host/core.ts +9 -2
  44. package/src/host/index.ts +60 -54
  45. package/src/host/parser.ts +155 -12
  46. package/src/host/queries.ts +289 -0
  47. package/src/host/types.ts +104 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * IDEA 式时间格式化:不足 60 分钟「x 分钟前」、今天「今天 HH:mm」、
3
+ * 昨天「昨天 HH:mm」、其余「Y/M/D HH:mm」。纯函数,可单元测试。
4
+ */
5
+
6
+ /** 本地化标签由调用方注入(组件侧经字典提供)。 */
7
+ export interface TimeLabels {
8
+ readonly minutesAgo: (n: number) => string
9
+ readonly today: string
10
+ readonly yesterday: string
11
+ }
12
+
13
+ const pad = (n: number): string => String(n).padStart(2, '0')
14
+
15
+ /** 日历日键(本地时区),用于今天/昨天判定。 */
16
+ function dayKey(x: Date): string {
17
+ return `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`
18
+ }
19
+
20
+ export function formatWhen(iso: string, now: number, labels: TimeLabels): string {
21
+ const then = Date.parse(iso)
22
+ if (!Number.isFinite(then)) return iso
23
+ const d = new Date(then)
24
+ const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
25
+ const seconds = Math.floor((now - then) / 1000)
26
+ if (seconds >= 0 && seconds < 3600) {
27
+ return labels.minutesAgo(Math.max(1, Math.floor(seconds / 60)))
28
+ }
29
+ if (dayKey(d) === dayKey(new Date(now))) return `${labels.today} ${hm}`
30
+ if (dayKey(d) === dayKey(new Date(now - 86_400_000))) return `${labels.yesterday} ${hm}`
31
+ return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${hm}`
32
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Client 插件平台契约:业务层定义「我需要宿主做什么」,适配层实现「宿主怎么做」。
3
+ *
4
+ * 当 dsh 升级导致 Cordis / typert / slot API 变更时,只需更新
5
+ * `adapters/dsh/client-adapter.ts`,本文件及业务代码零改动。
6
+ */
7
+ import type { GitAction, GitActionResult, GitActionRequest, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
8
+
9
+ /** 国际化字典:中英双语。 */
10
+ export interface LocaleDicts {
11
+ readonly zh: Record<string, string>
12
+ readonly en: Record<string, string>
13
+ }
14
+
15
+ /** RPC 信封:传输层结果(ok/error)包裹业务返回值。 */
16
+ export type RemoteEnvelope<T> =
17
+ | { readonly ok: true; readonly value: T }
18
+ | { readonly ok: false; readonly error: { readonly code?: string; readonly message?: string; readonly details?: unknown } }
19
+
20
+ /** Git Remote 服务接口:客户端通过此接口与宿主 RPC 通信。 */
21
+ export interface GitRemoteLike {
22
+ snapshot(request: GitSnapshotRequest): Promise<RemoteEnvelope<GitSnapshotResult>>
23
+ run(request: GitActionRequest): Promise<RemoteEnvelope<GitActionResult>>
24
+ query(request: GitQueryRequest): Promise<RemoteEnvelope<GitQueryResponse>>
25
+ }
26
+
27
+ /** 简化的查询结果:解包 RPC 信封和业务错误。 */
28
+ export type GitQueryOutcome =
29
+ | { readonly ok: true; readonly value: Extract<GitQueryResponse, { ok: true }>['value'] }
30
+ | { readonly ok: false; readonly message: string }
31
+
32
+ /** Git 视图状态:客户端组件消费的可观察快照。 */
33
+ export type GitView =
34
+ | { readonly state: 'no-cwd' }
35
+ | { readonly state: 'cold' }
36
+ | { readonly state: 'loading' }
37
+ | { readonly state: 'ready'; readonly snapshot: import('../host/types.ts').GitSnapshot }
38
+ | { readonly state: 'error'; readonly error: import('../host/types.ts').GitSnapshotFailure }
39
+
40
+ /** 可观察对象:useSyncExternalStore 形状。 */
41
+ export interface GitObservable<V> {
42
+ subscribe(listener: () => void): () => void
43
+ getSnapshot(): V
44
+ }
45
+
46
+ /** Slot 注入接口:组件通过此接口与控制器交互。 */
47
+ export interface GitInjected {
48
+ hooks: {
49
+ /** 会话的 Git 视图源。Slot 运行时将其绑定为 useGit 选择器钩子。 */
50
+ git: GitObservable<GitView>
51
+ }
52
+ /** 强制立即刷新(与轮询相同路径)。 */
53
+ refresh: () => Promise<void>
54
+ /** 执行一条管理操作(宿主返回新快照)。 */
55
+ run: (action: GitAction) => Promise<GitActionResult>
56
+ /** 执行一条只读查询(历史/差异/分支等)。 */
57
+ query: (query: GitQueryRequest['query']) => Promise<GitQueryOutcome>
58
+ }
59
+
60
+ /** Slot 条目描述符:注册到宿主 slot 系统的一条记录。 */
61
+ export interface SlotEntryDescriptor {
62
+ /** Slot 名称(如 'conversation.session.header.utilities')。 */
63
+ readonly name: string
64
+ /** 条目 ID(同一 slot 内唯一)。 */
65
+ readonly id: string
66
+ /** 渲染顺序(升序)。 */
67
+ readonly order?: number
68
+ /** 国际化命名空间。 */
69
+ readonly locale?: string
70
+ /** 注入工厂:每次渲染调用,返回该条目的业务接口。 */
71
+ readonly inject: (sessionId: string) => GitInjected
72
+ }
73
+
74
+ /**
75
+ * Client 平台能力:业务层需要的全部宿主服务。
76
+ *
77
+ * 设计原则:
78
+ * - 只声明「做什么」,不暴露「怎么做」
79
+ * - 方法签名取业务层实际使用的最小集
80
+ * - 返回类型为业务层需要的接口,非宿主原始类型
81
+ */
82
+ export interface ClientPlatform {
83
+ /** 注册国际化字典。 */
84
+ registerLocale(namespace: string, dicts: LocaleDicts): void
85
+
86
+ /**
87
+ * 挂载 Remote 贡献并返回命名空间服务对象。
88
+ *
89
+ * 挂载完成后,返回的对象可用于创建业务控制器。适配层负责处理
90
+ * 宿主的挂载机制(如 Cordis 的 child fiber + inject 模式)。
91
+ */
92
+ mountRemoteAndGetService(contribution: RemoteContribution, namespace: string): Promise<GitRemoteLike>
93
+
94
+ /**
95
+ * 注册一个 slot 条目。
96
+ *
97
+ * 宿主负责在合适的时机渲染该条目。返回释放函数,调用后注销条目。
98
+ */
99
+ registerSlotEntry(options: SlotEntryDescriptor, component: unknown): () => void
100
+
101
+ /** 订阅应用事件。返回取消订阅函数(若宿主支持)。 */
102
+ onEvent(event: string, listener: (...args: unknown[]) => void): (() => void) | void
103
+
104
+ /**
105
+ * 注册副作用。回调可返回清理函数,在插件卸载时调用。
106
+ * 用于管理定时器等需要生命周期管理的资源。
107
+ */
108
+ effect(callback: () => void | (() => void | Promise<void>), label?: string): void
109
+ }
110
+
111
+ /**
112
+ * Remote 贡献声明:描述一个命名空间下的全部 RPC 方法。
113
+ *
114
+ * 本接口是业务层对 RPC 声明的最小需求。适配层负责将其转换为宿主
115
+ * 的具体格式(如 TypertRemoteContribution)。
116
+ */
117
+ export interface RemoteContribution {
118
+ readonly package: string
119
+ readonly descriptors: readonly RemoteMethodDescriptor[]
120
+ }
121
+
122
+ /** 单条 RPC 方法描述。 */
123
+ export interface RemoteMethodDescriptor {
124
+ readonly id: string
125
+ readonly service: string
126
+ readonly namespace: string
127
+ readonly method: string
128
+ readonly invocation: { readonly kind: 'direct' } | { readonly kind: 'context'; readonly context: string; readonly wire: string }
129
+ readonly cancellation?: { readonly parameter: 'signal' }
130
+ readonly parameters: readonly RemoteParameterDescriptor[]
131
+ readonly result: RemoteCodecDescriptor
132
+ }
133
+
134
+ /** RPC 参数描述。 */
135
+ export interface RemoteParameterDescriptor {
136
+ readonly name: string
137
+ readonly wire: string
138
+ readonly source: 'json'
139
+ readonly codec: RemoteCodecDescriptor
140
+ }
141
+
142
+ /** RPC 编解码器描述。 */
143
+ export interface RemoteCodecDescriptor {
144
+ readonly mode: 'strict'
145
+ readonly typeSymbol: string
146
+ readonly schema: { parse(value: unknown): unknown }
147
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Host 端点契约:纯业务逻辑,零框架依赖。
3
+ *
4
+ * 将 `snapshotForSession` / `runAction` / `runQuery` 聚合为统一的
5
+ * `HostEndpoints` 接口——宿主适配层(Cordis/typert 或其他框架)只需调用
6
+ * `createHostEndpoints(deps, config)` 即可获得全部端点方法,再以任何
7
+ * RPC 机制暴露。业务层对此一无所知。
8
+ */
9
+ import { snapshotForSession, type GitStatusConfig, type SnapshotDeps } from '../host/core.ts'
10
+ import { runAction } from '../host/actions.ts'
11
+ import { runQuery } from '../host/queries.ts'
12
+ import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult } from '../host/types.ts'
13
+
14
+ /**
15
+ * 业务端点集合:三个 RPC 方法的纯函数实现。
16
+ * 每个方法接收 wire 请求、可选取消信号,返回 wire 响应。
17
+ */
18
+ export interface HostEndpoints {
19
+ snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult>
20
+ run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult>
21
+ query(request: GitQueryRequest, signal?: AbortSignal): Promise<GitQueryResponse>
22
+ }
23
+
24
+ /**
25
+ * 构造业务端点实例。
26
+ *
27
+ * `deps` 携带全部宿主能力(子进程、会话查找、文件系统);`config` 携带
28
+ * 运行参数。两者均为结构化接口,与任何框架无关。返回的端点对象可直接
29
+ * 绑定到 RPC 装饰器、HTTP 路由、或测试桩。
30
+ */
31
+ export function createHostEndpoints(deps: SnapshotDeps, config: GitStatusConfig): HostEndpoints {
32
+ return {
33
+ snapshot(request, signal) {
34
+ // 合并调用方信号与 deps 自带信号(deps.signal 来自 Cordis Remote 的
35
+ // 取消槽;此处 signal 来自适配层传入——两者取并集,任一触发即中止)。
36
+ const merged = mergeSignals(deps.signal, signal)
37
+ const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
38
+ return snapshotForSession(effectiveDeps, config, request.sessionId)
39
+ },
40
+ run(request, signal) {
41
+ const merged = mergeSignals(deps.signal, signal)
42
+ const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
43
+ return runAction(effectiveDeps, config, request)
44
+ },
45
+ query(request, signal) {
46
+ const merged = mergeSignals(deps.signal, signal)
47
+ const effectiveDeps = merged === deps.signal ? deps : { ...deps, signal: merged }
48
+ return runQuery(effectiveDeps, config, request)
49
+ },
50
+ }
51
+ }
52
+
53
+ /** 合并两个可选信号:任一 undefined 取另一个,均存在则 AbortSignal.any。 */
54
+ function mergeSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
55
+ if (a === undefined) return b
56
+ if (b === undefined) return a
57
+ return AbortSignal.any([a, b])
58
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 插件激活逻辑:纯业务编排,零框架依赖。
3
+ *
4
+ * 接收 `ClientPlatform` 接口和插件依赖,完成:
5
+ * 1. 注册国际化字典
6
+ * 2. 挂载 Remote 并获取 gitInfo 服务
7
+ * 3. 注册 header utility slot
8
+ * 4. 订阅连接重置事件
9
+ *
10
+ * 适配层(如 `adapters/dsh/client-adapter.ts`)负责将宿主 API 翻译为
11
+ * `ClientPlatform`,然后调用本函数。业务层对宿主一无所知。
12
+ */
13
+ import type { ClientPlatform, SlotEntryDescriptor, GitInjected, GitRemoteLike } from './client-platform.ts'
14
+ import type { RemoteContribution } from './client-platform.ts'
15
+ import type { LocaleDicts } from './client-platform.ts'
16
+ import type { GitAction } from '../host/types.ts'
17
+ import type { GitQueryRequest, GitActionResult } from '../host/types.ts'
18
+ import type { GitQueryOutcome } from './client-platform.ts'
19
+
20
+ /** Slot 名称常量。 */
21
+ const HEADER_UTILITIES_SLOT = 'conversation.session.header.utilities'
22
+
23
+ /** 本插件的 slot 条目 ID。 */
24
+ const SLOT_ENTRY_ID = 'git'
25
+
26
+ /** 国际化命名空间。 */
27
+ const LOCALE_NS = 'git'
28
+
29
+ /** 控制器接口:插件激活逻辑依赖的控制器能力。 */
30
+ export interface ControllerLike {
31
+ subscribe(listener: () => void): () => void
32
+ getSnapshot(): import('./client-platform.ts').GitView
33
+ refresh(): Promise<void>
34
+ resync(): void
35
+ run(action: GitAction): Promise<GitActionResult>
36
+ query(query: GitQueryRequest['query']): Promise<GitQueryOutcome>
37
+ dispose(): void
38
+ }
39
+
40
+ /** 插件依赖:由调用方提供的具体实现。 */
41
+ export interface PluginDependencies {
42
+ /** Remote 贡献配置 */
43
+ remoteContribution: RemoteContribution
44
+ /** 国际化字典 */
45
+ locales: LocaleDicts
46
+ /** 控制器构造函数 */
47
+ createController: (remote: GitRemoteLike, sessionId: string) => ControllerLike
48
+ }
49
+
50
+ /**
51
+ * 激活插件。
52
+ *
53
+ * @param platform 宿主平台能力
54
+ * @param deps 插件依赖(由调用方提供)
55
+ * @param component 要注册的 UI 组件(已包裹 UI 基础组件上下文)
56
+ */
57
+ export async function activatePlugin(
58
+ platform: ClientPlatform,
59
+ deps: PluginDependencies,
60
+ component: unknown,
61
+ ): Promise<void> {
62
+ // 1. 注册国际化字典
63
+ platform.effect(
64
+ () => platform.registerLocale(LOCALE_NS, deps.locales),
65
+ 'dsh-git-ui: dictionaries',
66
+ )
67
+
68
+ // 2. 挂载 Remote 并获取 gitInfo 服务
69
+ const gitInfoService = await platform.mountRemoteAndGetService(deps.remoteContribution, 'gitInfo')
70
+
71
+ // 3. 控制器缓存(按 sessionId)
72
+ const controllers = new Map<string, ControllerLike>()
73
+ const faces = new Map<string, GitInjected>()
74
+
75
+ const controllerFor = (sessionId: string): ControllerLike => {
76
+ let controller = controllers.get(sessionId)
77
+ if (controller === undefined) {
78
+ controller = deps.createController(gitInfoService, sessionId)
79
+ controllers.set(sessionId, controller)
80
+ }
81
+ return controller
82
+ }
83
+
84
+ // 4. 注册 slot 条目
85
+ const slotOptions: SlotEntryDescriptor = {
86
+ name: HEADER_UTILITIES_SLOT,
87
+ id: SLOT_ENTRY_ID,
88
+ order: 10,
89
+ locale: LOCALE_NS,
90
+ inject: (sessionId): GitInjected => {
91
+ // Per-session stable face: the slot runtime may re-invoke the
92
+ // inject factory on every render, and components depend on the
93
+ // `refresh` reference staying stable (a fresh arrow function per
94
+ // call would re-run mount effects and loop: refresh → view
95
+ // change → re-render → new refresh → refresh …). Cache the face
96
+ // so the same controller (and its bound refresh/run) is always
97
+ // handed out per session.
98
+ let face = faces.get(sessionId)
99
+ if (face === undefined) {
100
+ const controller = controllerFor(sessionId)
101
+ face = {
102
+ hooks: { git: controller as GitInjected['hooks']['git'] },
103
+ refresh: () => controller.refresh(),
104
+ run: (action) => controller.run(action),
105
+ query: (query) => controller.query(query),
106
+ }
107
+ faces.set(sessionId, face)
108
+ }
109
+ return face
110
+ },
111
+ }
112
+
113
+ const disposeSlot = platform.registerSlotEntry(slotOptions, component)
114
+
115
+ // 5. 注册清理:释放 slot 并销毁全部控制器
116
+ platform.effect(() => {
117
+ return () => {
118
+ disposeSlot()
119
+ for (const controller of controllers.values()) controller.dispose()
120
+ controllers.clear()
121
+ faces.clear()
122
+ }
123
+ }, 'dsh-git-ui: slot and controller lifecycle')
124
+
125
+ // 6. 订阅连接重置事件:重连后刷新全部控制器
126
+ platform.onEvent('connection/reset', () => {
127
+ for (const controller of controllers.values()) controller.resync()
128
+ })
129
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * UI 基础组件 React Context:业务组件通过 `useUI()` 获取实现,
3
+ * 适配层在插件激活时通过 `<UIPrimitivesProvider>` 注入宿主组件。
4
+ *
5
+ * 设计要点:
6
+ * - 默认值为 null——若未提供则 `useUI()` 抛出明确错误,而非静默渲染空组件。
7
+ * - Provider 在插件入口(client/index.ts)包裹整棵组件树。
8
+ * - 测试可直接用 Provider 注入 mock 组件,无需模拟宿主环境。
9
+ */
10
+ import { createContext, useContext } from 'react'
11
+ import type { ReactNode } from 'react'
12
+ import type { UIPrimitives } from './ui-primitives.ts'
13
+
14
+ const UIPrimitivesContext = createContext<UIPrimitives | null>(null)
15
+
16
+ /** 消费 UI 基础组件。必须在 `<UIPrimitivesProvider>` 内使用。 */
17
+ export function useUI(): UIPrimitives {
18
+ const value = useContext(UIPrimitivesContext)
19
+ if (value === null) {
20
+ throw new Error('useUI(): missing <UIPrimitivesProvider> — 插件入口未注入 UI 基础组件')
21
+ }
22
+ return value
23
+ }
24
+
25
+ /** 注入 UI 基础组件实现。在插件入口包裹整棵组件树。 */
26
+ export function UIPrimitivesProvider({ value, children }: { value: UIPrimitives; children?: ReactNode }): ReactNode {
27
+ return <UIPrimitivesContext.Provider value={value}>{children}</UIPrimitivesContext.Provider>
28
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * UI 基础组件契约:业务层只依赖此接口,不直接 import 任何宿主 UI 库。
3
+ *
4
+ * 属性集取「实际使用的最小并集」——GitPill / GitCenter 用到的全部 props
5
+ * 均已覆盖;宿主适配层可提供更丰富的实现,但业务代码保证只消费此处的
6
+ * 声明。未来 dsh 升级导致 Modal/Button/Toast 变更时,只需更新适配层,
7
+ * 业务组件零改动。
8
+ */
9
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
10
+
11
+ /** 受控全屏对话框:遮罩 + Escape 关闭。 */
12
+ export interface ModalProps {
13
+ readonly open: boolean
14
+ readonly onClose: () => void
15
+ readonly title: string
16
+ readonly closeLabel?: string
17
+ readonly description?: string
18
+ readonly children?: ReactNode
19
+ readonly footer?: ReactNode
20
+ readonly className?: string
21
+ readonly contentClassName?: string
22
+ /** 隐藏默认标题栏(自定义头部时使用)。 */
23
+ readonly headless?: boolean
24
+ }
25
+
26
+ /** 令牌化按钮原子。原生 button 属性透传。 */
27
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
28
+ readonly variant?: 'primary' | 'ghost' | 'outline' | 'toolbar'
29
+ readonly size?: 'md' | 'sm'
30
+ readonly icon?: ReactNode
31
+ readonly className?: string | undefined
32
+ readonly children?: ReactNode
33
+ }
34
+
35
+ /** 瞬时顶部提示条:自动消失后调用 onDone 通知父级卸载。 */
36
+ export interface ToastProps {
37
+ readonly text: string
38
+ readonly icon?: ReactNode
39
+ readonly anchor?: HTMLElement | null
40
+ readonly onDone: () => void
41
+ }
42
+
43
+ /** 三个基础组件的聚合:适配层一次性提供整套实现。 */
44
+ export interface UIPrimitives {
45
+ readonly Modal: (props: ModalProps) => ReactNode
46
+ readonly Button: (props: ButtonProps) => ReactNode
47
+ readonly Toast: (props: ToastProps) => ReactNode
48
+ }
@@ -15,21 +15,21 @@
15
15
  */
16
16
  import { resolve, sep } from 'node:path'
17
17
  import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
18
- import type { GitAction, GitActionResult, GitActionRequest } from './types.ts'
18
+ import type { GitAction, GitActionResult, GitActionRequest, GitOperationErrorCode } from './types.ts'
19
19
 
20
20
  /** Build the command sequence for one action, validating every path against the root. */
21
21
  function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
22
22
  switch (action.kind) {
23
23
  case 'stage':
24
- return withPaths(['git', 'add', '--'], action.paths, root)
24
+ return withPaths([['git', 'add', '--']], action.paths, root)
25
25
  case 'stage-all':
26
26
  return { argv: [['git', 'add', '-A']] }
27
27
  case 'unstage':
28
- return withPaths(['git', 'restore', '--staged', '--'], action.paths, root)
28
+ return withPaths([['git', 'restore', '--staged', '--']], action.paths, root)
29
29
  case 'unstage-all':
30
30
  return { argv: [['git', 'restore', '--staged', '--', '.']] }
31
31
  case 'discard':
32
- return withPaths(['git', 'restore', '--'], action.paths, root)
32
+ return withPaths([['git', 'restore', '--']], action.paths, root)
33
33
  case 'discard-all':
34
34
  // Reset the index to HEAD first, then the work tree to the index — the
35
35
  // IDE-style "roll back everything tracked" semantics.
@@ -40,21 +40,49 @@ function buildArgv(action: GitAction, root: string): { readonly argv: readonly (
40
40
  if (action.paths === undefined || action.paths.length === 0) {
41
41
  return { argv: [['git', 'commit', '-m', message]] }
42
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)
43
+ // 两步序列(IDE 式「提交所选文件」语义,含未跟踪文件):
44
+ // 1. `git add -- <paths>` 先把所选路径纳入索引——裸的
45
+ // `git commit -- <未跟踪路径>` 会报 pathspec 错误,先行暂存使其可匹配;
46
+ // 2. `git commit -m <msg> -- <paths>` 按路径限定提交这些路径的工作区内容,
47
+ // 其余已暂存文件不受影响。对已跟踪路径与单命令完全等价(已实测验证)。
48
+ return withPaths([['git', 'add', '--'], ['git', 'commit', '-m', message, '--']], action.paths, root)
47
49
  }
50
+ case 'branch-create': {
51
+ // Name validity is validated by runAction (invalid-name), not here.
52
+ const from = action.from === undefined || action.from === '' ? [] : [action.from]
53
+ return { argv: [['git', 'branch', action.name, ...from]] }
54
+ }
55
+ case 'branch-checkout':
56
+ return { argv: [['git', 'checkout', action.name]] }
57
+ case 'branch-delete':
58
+ return { argv: [['git', 'branch', action.force === true ? '-D' : '-d', action.name]] }
59
+ case 'fetch':
60
+ // fetch --all --prune:拉取所有远程引用更新 + 清理已删除的远程跟踪分支。
61
+ return { argv: [['git', 'fetch', '--all', '--prune']] }
48
62
  }
49
63
  }
50
64
 
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 } {
65
+ /**
66
+ * A branch name is valid when it matches git's ref-name grammar at the level
67
+ * we care about: non-empty, ASCII ref chars only, no leading `-` (option
68
+ * injection guard, though argv never shells out), no `..` (path traversal of
69
+ * refs), no trailing `/`, and no double slashes.
70
+ */
71
+ export function isValidBranchName(name: string): boolean {
72
+ if (name === '' || name.startsWith('-') || name.includes('..') || name.endsWith('/') || name.includes('//')) return false
73
+ return /^[A-Za-z0-9._/-]+$/.test(name)
74
+ }
75
+
76
+ /**
77
+ * 校验仓库相对路径后追加到 `--` 之后;`prefixes` 可给出多条命令序列,
78
+ * 校验后的路径逐一附加到每条序列(commit 所选路径即两步序列)。
79
+ */
80
+ function withPaths(prefixes: readonly (readonly string[])[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
53
81
  if (paths.length === 0) return { error: 'no paths given' }
54
82
  for (const path of paths) {
55
83
  if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
56
84
  }
57
- return { argv: [[...prefix, ...paths]] }
85
+ return { argv: prefixes.map((prefix) => [...prefix, ...paths]) }
58
86
  }
59
87
 
60
88
  /**
@@ -71,13 +99,26 @@ export function isSafePath(path: string, root: string): boolean {
71
99
  }
72
100
 
73
101
  /** 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 } {
102
+ export function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
75
103
  if (failure.code === 'git-unavailable') {
76
104
  return { ok: false, error: { code: 'git-error', message: failure.detail } }
77
105
  }
78
106
  return { ok: false, error: failure }
79
107
  }
80
108
 
109
+ /**
110
+ * 把 git 命令失败归类为可预期的业务错误(其余保持 git-error)。
111
+ * 切分支被工作区未提交变更阻止是最常见的可预期失败:git 输出
112
+ * "would be overwritten by checkout"(或中文本地化 "将被 checkout 覆盖"),
113
+ * 归一化为 local-changes-block,client 据此给友好提示 + 处理变更引导。
114
+ */
115
+ export function classifyOperationError(kind: GitAction['kind'], message: string): GitOperationErrorCode {
116
+ if (kind === 'branch-checkout' && /would be overwritten by checkout|将被 checkout 覆盖|有未跟踪工作区文件将会被 checkout 覆盖/i.test(message)) {
117
+ return 'local-changes-block'
118
+ }
119
+ return 'git-error'
120
+ }
121
+
81
122
  /**
82
123
  * Execute one management action against the session's repository and return
83
124
  * the refreshed snapshot on success (the caller re-renders from it, so the
@@ -96,11 +137,20 @@ export async function runAction(
96
137
  return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
97
138
  }
98
139
 
140
+ const kind = request.action.kind
141
+ if (kind === 'branch-create' || kind === 'branch-checkout' || kind === 'branch-delete') {
142
+ const name = request.action.name
143
+ if (!isValidBranchName(name)) {
144
+ return { ok: false, error: { code: 'invalid-name', message: `invalid branch name: ${name}` } }
145
+ }
146
+ }
147
+
99
148
  const built = buildArgv(request.action, root)
100
149
  if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
101
150
 
102
- // Run the command sequence; a failure stops the rest (the first commands
103
- // may already have taken effect — they are all idempotent restores).
151
+ // Run the command sequence; a failure stops the rest. 先行命令可能已生效:
152
+ // restore 类命令幂等可重入;两步提交若 add 成功后 commit 失败,所选路径
153
+ // 留在暂存区(IDE 行为相同,下次重试即可成功)。
104
154
  let lastStdout = ''
105
155
  for (const argv of built.argv) {
106
156
  const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
@@ -110,10 +160,11 @@ export async function runAction(
110
160
  // git writes user-facing failures to stderr OR stdout (e.g. a clean
111
161
  // repo's `git commit` reports "nothing to commit" on stdout).
112
162
  const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
163
+ const code = classifyOperationError(request.action.kind, message)
113
164
  return {
114
165
  ok: false,
115
166
  error: {
116
- code: 'git-error',
167
+ code,
117
168
  message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
118
169
  },
119
170
  }
package/src/host/core.ts CHANGED
@@ -157,8 +157,14 @@ export async function resolveWorkspace(
157
157
  * 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
158
158
  * 2. `git branch --show-current` — null when detached
159
159
  * 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
160
- * 4. `git status --porcelain=v1 -z --branch`
160
+ * 4. `git status --porcelain=v1 -z --branch --untracked-files=all`
161
161
  * 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
162
+ *
163
+ * --untracked-files=all:git 默认 normal 模式会把整目录未跟踪折叠为单条
164
+ * `?? dir/`(尾斜杠)且不枚举其内部文件——隐藏目录(.agent/.tianqi 等)的
165
+ * 变更因此从不进入变更清单。`all` 强制逐文件枚举(与 IDEA / VSCode 一致),
166
+ * 内部文件得以展示;maxChanges 截断列表、maxStatusBytes spill 保计数精确,
167
+ * 超大未跟踪树(如未 gitignore 的构建产物)经此路径优雅降级。
162
168
  */
163
169
  export async function snapshotForSession(
164
170
  deps: SnapshotDeps,
@@ -182,7 +188,8 @@ export async function snapshotForSession(
182
188
  // main`), so a corrupt repo is never misreported as "no commits".
183
189
  const head = headRun.run.exitCode === 0 ? (headRun.run.stdout.trim() || null) : null
184
190
 
185
- const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch'], root, 'status', deps.signal)
191
+ // --untracked-files=all:强制枚举未跟踪目录内部文件(根因修复——见模块注释)。
192
+ const status = await runCommand(deps.run, ['git', 'status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], root, 'status', deps.signal)
186
193
  if ('failure' in status) return { ok: false, error: status.failure }
187
194
  if (status.run.timedOut) return { ok: false, error: { code: 'timeout' } }
188
195
  if (status.run.exitCode !== 0) {