opencode-extended-sidebar 0.2.90 → 0.2.91

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 (108) hide show
  1. package/.oesignore +17 -17
  2. package/CHANGELOG.md +96 -95
  3. package/LICENSE +21 -21
  4. package/README.md +150 -235
  5. package/oes.json +13 -11
  6. package/package.json +100 -100
  7. package/scripts/install-git-hooks.mjs +29 -29
  8. package/src/pware.oc.core/constants/index.ts +18 -18
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -29
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +15 -13
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -241
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -55
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -65
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -58
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -63
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -34
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -74
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -69
  19. package/src/pware.oc.core/git/index.ts +7 -7
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -237
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -87
  22. package/src/pware.oc.core/index.ts +19 -18
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -42
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -48
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -97
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -280
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -79
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -135
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -234
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +99 -89
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -240
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -74
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -726
  34. package/src/pware.oc.core/pware.oc.core.runtimeConfig.ts +41 -0
  35. package/src/pware.oc.core/pware.oc.core.sqlite.ts +164 -175
  36. package/src/pware.oc.core/pware.oc.core.sqliteGateway.ts +89 -0
  37. package/src/pware.oc.core/pware.oc.core.status.ts +214 -214
  38. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -63
  39. package/src/pware.oc.core/pware.oc.core.width.ts +215 -215
  40. package/src/pware.oc.omo/constants/index.ts +15 -15
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -18
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -49
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -29
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -11
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -60
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -49
  47. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -25
  48. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -38
  49. package/src/pware.oc.omo/index.ts +7 -7
  50. package/src/pware.oc.omo/resolver/index.ts +19 -19
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -179
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -116
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -34
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -503
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -40
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -269
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -18
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -18
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -155
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +363 -361
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -18
  62. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -11
  63. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -14
  64. package/src/pware.oc.opencode/constants/index.ts +10 -10
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -15
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -18
  67. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -45
  68. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -58
  69. package/src/pware.oc.opencode/index.ts +8 -8
  70. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -182
  71. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -399
  72. package/src/pware.oc.opencode/resolver/index.ts +223 -170
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +150 -162
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +149 -185
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +248 -211
  76. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +22 -26
  77. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +224 -236
  78. package/src/pware.oc.perf/index.ts +14 -14
  79. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -15
  80. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -284
  81. package/src/pware.oc.perf/pware.oc.perf.reader.ts +962 -927
  82. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -88
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -118
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -79
  85. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -71
  86. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -214
  87. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -179
  88. package/src/pware.oc.perf/pware.oc.perf.view.tsx +463 -462
  89. package/src/pware.oc.runtime/index.ts +13 -13
  90. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -142
  91. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +87 -79
  92. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +282 -253
  93. package/src/pware.oc.runtime/pware.oc.runtime.omoRead.ts +37 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +83 -95
  95. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +130 -129
  96. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -105
  97. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +44 -43
  98. package/src/pware.oc.runtime/resolver/index.ts +268 -224
  99. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -146
  100. package/src/pware.oc.ui/index.ts +12 -12
  101. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +381 -339
  102. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -144
  103. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -111
  104. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -82
  105. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -843
  106. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +552 -526
  107. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1718 -1629
  108. package/src/pware.oc.ui.tsx +98 -98
@@ -1,69 +1,69 @@
1
- /**
2
- * pware.oc.core.constants.toolname
3
- *
4
- * OpenCode tool names grouped by file-touch behavior (the plugin's
5
- * WRITE_TOOLS / READ_TOOLS in files.ts) plus the special non-file tools the
6
- * code classifies (`bash`, `task`, `question`).
7
- */
8
-
9
- /** Tool name that edits an existing file. */
10
- export const TOOL_EDIT = "edit"
11
-
12
- /** Tool name that writes a file. */
13
- export const TOOL_WRITE = "write"
14
-
15
- /** Tool name for a multi-file edit. */
16
- export const TOOL_MULTIEDIT = "multiedit"
17
-
18
- /** Tool name for applying a prebuilt edit (underscore spelling). */
19
- export const TOOL_APPLY_EDIT = "apply_edit"
20
-
21
- /** Tool name for applying a prebuilt edit (compact spelling). */
22
- export const TOOL_APPLYEDIT = "applyedit"
23
-
24
- /** Tool name that deletes a file. */
25
- export const TOOL_DELETE = "delete"
26
-
27
- /** Tool name that removes a file. */
28
- export const TOOL_REMOVE = "remove"
29
-
30
- /** Tool name that reads a file. */
31
- export const TOOL_READ = "read"
32
-
33
- /** Tool name that views a file. */
34
- export const TOOL_VIEW = "view"
35
-
36
- /** Tool name that reads a file (underscore spelling). */
37
- export const TOOL_READ_FILE = "read_file"
38
-
39
- /** Tool name that reads a file (compact spelling). */
40
- export const TOOL_READFILE = "readfile"
41
-
42
- /** Tool name for a shell command — a non-file tool the panel classifies. */
43
- export const TOOL_BASH = "bash"
44
-
45
- /** Tool name for delegating a subtask — a non-file tool the panel classifies. */
46
- export const TOOL_TASK = "task"
47
-
48
- /** Tool name for asking the user a question — a non-file tool the panel classifies. */
49
- export const TOOL_QUESTION = "question"
50
-
51
- /** Tools that write/delete files — mirrors files.ts WRITE_TOOLS. */
52
- export const WRITE_TOOLS = [
53
- TOOL_EDIT,
54
- TOOL_WRITE,
55
- TOOL_MULTIEDIT,
56
- TOOL_APPLY_EDIT,
57
- TOOL_APPLYEDIT,
58
- TOOL_DELETE,
59
- TOOL_REMOVE,
60
- ] as const
61
-
62
- /** Tools that read/view files — mirrors files.ts READ_TOOLS. */
63
- export const READ_TOOLS = [TOOL_READ, TOOL_VIEW, TOOL_READ_FILE, TOOL_READFILE] as const
64
-
65
- /** Non-file tools the panel classifies separately from file-touch behavior. */
66
- export const NON_FILE_TOOLS = [TOOL_BASH, TOOL_TASK, TOOL_QUESTION] as const
67
-
68
- /** A tool name the panel classifies by file-touch or special behavior. */
69
- export type ToolName = (typeof WRITE_TOOLS)[number] | (typeof READ_TOOLS)[number] | (typeof NON_FILE_TOOLS)[number]
1
+ /**
2
+ * pware.oc.core.constants.toolname
3
+ *
4
+ * OpenCode tool names grouped by file-touch behavior (the plugin's
5
+ * WRITE_TOOLS / READ_TOOLS in files.ts) plus the special non-file tools the
6
+ * code classifies (`bash`, `task`, `question`).
7
+ */
8
+
9
+ /** Tool name that edits an existing file. */
10
+ export const TOOL_EDIT = "edit"
11
+
12
+ /** Tool name that writes a file. */
13
+ export const TOOL_WRITE = "write"
14
+
15
+ /** Tool name for a multi-file edit. */
16
+ export const TOOL_MULTIEDIT = "multiedit"
17
+
18
+ /** Tool name for applying a prebuilt edit (underscore spelling). */
19
+ export const TOOL_APPLY_EDIT = "apply_edit"
20
+
21
+ /** Tool name for applying a prebuilt edit (compact spelling). */
22
+ export const TOOL_APPLYEDIT = "applyedit"
23
+
24
+ /** Tool name that deletes a file. */
25
+ export const TOOL_DELETE = "delete"
26
+
27
+ /** Tool name that removes a file. */
28
+ export const TOOL_REMOVE = "remove"
29
+
30
+ /** Tool name that reads a file. */
31
+ export const TOOL_READ = "read"
32
+
33
+ /** Tool name that views a file. */
34
+ export const TOOL_VIEW = "view"
35
+
36
+ /** Tool name that reads a file (underscore spelling). */
37
+ export const TOOL_READ_FILE = "read_file"
38
+
39
+ /** Tool name that reads a file (compact spelling). */
40
+ export const TOOL_READFILE = "readfile"
41
+
42
+ /** Tool name for a shell command — a non-file tool the panel classifies. */
43
+ export const TOOL_BASH = "bash"
44
+
45
+ /** Tool name for delegating a subtask — a non-file tool the panel classifies. */
46
+ export const TOOL_TASK = "task"
47
+
48
+ /** Tool name for asking the user a question — a non-file tool the panel classifies. */
49
+ export const TOOL_QUESTION = "question"
50
+
51
+ /** Tools that write/delete files — mirrors files.ts WRITE_TOOLS. */
52
+ export const WRITE_TOOLS = [
53
+ TOOL_EDIT,
54
+ TOOL_WRITE,
55
+ TOOL_MULTIEDIT,
56
+ TOOL_APPLY_EDIT,
57
+ TOOL_APPLYEDIT,
58
+ TOOL_DELETE,
59
+ TOOL_REMOVE,
60
+ ] as const
61
+
62
+ /** Tools that read/view files — mirrors files.ts READ_TOOLS. */
63
+ export const READ_TOOLS = [TOOL_READ, TOOL_VIEW, TOOL_READ_FILE, TOOL_READFILE] as const
64
+
65
+ /** Non-file tools the panel classifies separately from file-touch behavior. */
66
+ export const NON_FILE_TOOLS = [TOOL_BASH, TOOL_TASK, TOOL_QUESTION] as const
67
+
68
+ /** A tool name the panel classifies by file-touch or special behavior. */
69
+ export type ToolName = (typeof WRITE_TOOLS)[number] | (typeof READ_TOOLS)[number] | (typeof NON_FILE_TOOLS)[number]
@@ -1,7 +1,7 @@
1
- /**
2
- * pware.oc.core.git
3
- *
4
- * Read-only git status + ignore rules for the Files panel.
5
- */
6
- export * from "./pware.oc.core.git.js"
7
- export * from "./pware.oc.core.gitignore.js"
1
+ /**
2
+ * pware.oc.core.git
3
+ *
4
+ * Read-only git status + ignore rules for the Files panel.
5
+ */
6
+ export * from "./pware.oc.core.git.js"
7
+ export * from "./pware.oc.core.gitignore.js"
@@ -1,237 +1,237 @@
1
- /**
2
- * Read-only git status for Files letters. No repo / no git → empty, never throws.
3
- * Status is scoped to the listed paths — never the whole worktree.
4
- * Spawn is async: the TUI keeps the last letters until git returns.
5
- */
6
- import { spawn } from "node:child_process"
7
- import fs from "node:fs"
8
- import path from "node:path"
9
- import { fileStamp } from "../pware.oc.core.paths.js"
10
-
11
- /** Porcelain XY letters only. `?` is untracked. Do not invent extras here. */
12
- export type GitLetter = "M" | "A" | "D" | "R" | "C" | "U" | "T" | "?"
13
-
14
- const GIT_PATH_CAP = 40
15
- const GIT_DEBOUNCE_MS = 2500
16
- const GIT_TIMEOUT_MS = 1500
17
-
18
- export function findGitRoot(start?: string | null): string | null {
19
- if (!start) return null
20
- let dir = path.resolve(start)
21
- for (let i = 0; i < 16; i++) {
22
- try {
23
- if (fs.existsSync(path.join(dir, ".git"))) return dir
24
- } catch {
25
- return null
26
- }
27
- const parent = path.dirname(dir)
28
- if (parent === dir) break
29
- dir = parent
30
- }
31
- return null
32
- }
33
-
34
- export function gitStatusStamp(projectRoot?: string | null): string {
35
- const root = findGitRoot(projectRoot)
36
- if (!root) return "0"
37
- return [fileStamp(path.join(root, ".git")), fileStamp(path.join(root, ".git", "HEAD")), fileStamp(path.join(root, ".git", "index"))].join("|")
38
- }
39
-
40
- function porcelainLetter(xy: string): GitLetter | null {
41
- if (xy === "??") return "?"
42
- if (xy === "!!") return null
43
- const x = xy[0] ?? " "
44
- const y = xy[1] ?? " "
45
- if (x === "U" || y === "U" || xy === "AA" || xy === "DD") return "U"
46
- const rank = (c: string): GitLetter | null => {
47
- if (c === "D") return "D"
48
- if (c === "R") return "R"
49
- if (c === "C") return "C"
50
- if (c === "A") return "A"
51
- if (c === "T") return "T"
52
- if (c === "M") return "M"
53
- return null
54
- }
55
- return rank(x) ?? rank(y)
56
- }
57
-
58
- function xIsRename(x: string): boolean {
59
- return x === "R" || x === "C"
60
- }
61
-
62
- /** Parse `git status --porcelain -z` stdout. Exported for tests. */
63
- export function parsePorcelainZ(buf: string): Map<string, GitLetter> {
64
- const out = new Map<string, GitLetter>()
65
- let i = 0
66
- while (i < buf.length) {
67
- const xy = buf.slice(i, i + 2)
68
- if (xy.length < 2) break
69
- i += 2
70
- if (buf[i] === " ") i += 1
71
- const z = buf.indexOf("\0", i)
72
- if (z < 0) break
73
- const p1 = buf.slice(i, z).replace(/\\/g, "/")
74
- i = z + 1
75
- if (xIsRename(xy[0] ?? "")) {
76
- const z2 = buf.indexOf("\0", i)
77
- if (z2 >= 0) i = z2 + 1
78
- }
79
- const letter = porcelainLetter(xy)
80
- if (letter && p1) out.set(p1.toLowerCase(), letter)
81
- }
82
- return out
83
- }
84
-
85
- export function relToGitRoot(posixPath: string, root: string): string {
86
- const file = posixPath.replace(/\\/g, "/")
87
- const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
88
- const lower = file.toLowerCase()
89
- const rootLower = base.toLowerCase()
90
- if (lower.startsWith(`${rootLower}/`)) return lower.slice(rootLower.length + 1)
91
- return lower.replace(/^\.\//, "")
92
- }
93
-
94
- /** Pathspecs for `git status`: repo paths only, deduped, capped. Exported for tests. */
95
- export function relsFrom(posixPaths: string[], root: string): string[] {
96
- const base = root.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
97
- const seen = new Set<string>()
98
- const out: string[] = []
99
- for (const p of posixPaths) {
100
- const file = p.replace(/\\/g, "/")
101
- // git aborts the whole status with exit 128 on a single outside-repo
102
- // pathspec — sessions touch temp/config files outside the tree all the time.
103
- if (!file.toLowerCase().startsWith(`${base}/`)) continue
104
- const rel = relToGitRoot(p, root)
105
- if (!rel || seen.has(rel)) continue
106
- seen.add(rel)
107
- out.push(rel)
108
- if (out.length >= GIT_PATH_CAP) break
109
- }
110
- return out
111
- }
112
-
113
- let cacheKey = ""
114
- let cacheMarks = new Map<string, GitLetter>()
115
- let cacheRoot: string | null = null
116
- let lastPathKey = ""
117
- let lastSpawn = 0
118
- let pendingKey = ""
119
- let spawnGen = 0
120
- let inFlight = false
121
-
122
- const listeners = new Set<() => void>()
123
-
124
- function notify(): void {
125
- for (const fn of listeners) {
126
- try {
127
- fn()
128
- } catch {
129
- // sidebar teardown
130
- }
131
- }
132
- }
133
-
134
- /** Re-render when an async git status lands. Returns unsubscribe. */
135
- export function onGitMarksChange(fn: () => void): () => void {
136
- listeners.add(fn)
137
- return () => {
138
- listeners.delete(fn)
139
- }
140
- }
141
-
142
- /** Drop cached letters and in-flight spawn accounting (tests). */
143
- export function resetGitCache(): void {
144
- spawnGen += 1
145
- inFlight = false
146
- cacheKey = ""
147
- pendingKey = ""
148
- lastPathKey = ""
149
- lastSpawn = 0
150
- cacheRoot = null
151
- cacheMarks = new Map()
152
- }
153
-
154
- function runGit(root: string, rels: string[], key: string, gen: number): void {
155
- let child: ReturnType<typeof spawn>
156
- try {
157
- child = spawn(
158
- "git",
159
- ["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--", ...rels],
160
- { cwd: root, windowsHide: true },
161
- )
162
- } catch {
163
- if (gen === spawnGen) inFlight = false
164
- return
165
- }
166
- let out = ""
167
- const timer = setTimeout(() => {
168
- try {
169
- child.kill()
170
- } catch {
171
- // already gone
172
- }
173
- }, GIT_TIMEOUT_MS)
174
- child.stdout?.setEncoding("utf8")
175
- child.stdout?.on("data", (chunk: string) => {
176
- out += chunk
177
- })
178
- child.stderr?.resume()
179
- const finish = (ok: boolean) => {
180
- clearTimeout(timer)
181
- if (gen !== spawnGen) return
182
- inFlight = false
183
- cacheRoot = root
184
- cacheKey = key
185
- if (ok) cacheMarks = parsePorcelainZ(out)
186
- notify()
187
- }
188
- child.on("error", () => finish(false))
189
- child.on("close", (code) => finish(code === 0))
190
- }
191
-
192
- /**
193
- * Last known porcelain letters. Never waits on git — a stale map is returned
194
- * and a spawn is scheduled when the cache key is cold (2.5 s debounce on index noise).
195
- */
196
- export function readGitMarksFor(
197
- posixPaths: string[],
198
- projectRoot?: string | null,
199
- ): {
200
- root: string | null
201
- marks: Map<string, GitLetter>
202
- } {
203
- const root = findGitRoot(projectRoot)
204
- if (!root || posixPaths.length === 0) {
205
- return { root, marks: new Map() }
206
- }
207
- const rels = relsFrom(posixPaths, root)
208
- if (rels.length === 0) {
209
- return { root, marks: new Map() }
210
- }
211
- const pathKey = rels.slice().sort().join("\0")
212
- const stamp = gitStatusStamp(projectRoot)
213
- const key = `${root}|${stamp}|${pathKey}`
214
- cacheRoot = root
215
- if (key === cacheKey) return { root, marks: cacheMarks }
216
- if (inFlight && pendingKey === key) return { root, marks: cacheMarks }
217
-
218
- const now = Date.now()
219
- if (pathKey === lastPathKey && lastSpawn > 0 && now - lastSpawn < GIT_DEBOUNCE_MS) {
220
- return { root, marks: cacheMarks }
221
- }
222
-
223
- pendingKey = key
224
- lastPathKey = pathKey
225
- lastSpawn = now
226
- spawnGen += 1
227
- inFlight = true
228
- runGit(root, rels, key, spawnGen)
229
- return { root, marks: cacheMarks }
230
- }
231
-
232
- export function gitLetterFor(posixPath: string, projectRoot?: string | null): GitLetter | null {
233
- const { root, marks } = readGitMarksFor([posixPath], projectRoot)
234
- if (!root || marks.size === 0) return null
235
- const rel = relToGitRoot(posixPath, root)
236
- return marks.get(rel) ?? marks.get(posixPath.replace(/\\/g, "/").toLowerCase()) ?? null
237
- }
1
+ /**
2
+ * Read-only git status for Files letters. No repo / no git → empty, never throws.
3
+ * Status is scoped to the listed paths — never the whole worktree.
4
+ * Spawn is async: the TUI keeps the last letters until git returns.
5
+ */
6
+ import { spawn } from "node:child_process"
7
+ import fs from "node:fs"
8
+ import path from "node:path"
9
+ import { fileStamp } from "../pware.oc.core.paths.js"
10
+
11
+ /** Porcelain XY letters only. `?` is untracked. Do not invent extras here. */
12
+ export type GitLetter = "M" | "A" | "D" | "R" | "C" | "U" | "T" | "?"
13
+
14
+ const GIT_PATH_CAP = 40
15
+ const GIT_DEBOUNCE_MS = 2500
16
+ const GIT_TIMEOUT_MS = 1500
17
+
18
+ export function findGitRoot(start?: string | null): string | null {
19
+ if (!start) return null
20
+ let dir = path.resolve(start)
21
+ for (let i = 0; i < 16; i++) {
22
+ try {
23
+ if (fs.existsSync(path.join(dir, ".git"))) return dir
24
+ } catch {
25
+ return null
26
+ }
27
+ const parent = path.dirname(dir)
28
+ if (parent === dir) break
29
+ dir = parent
30
+ }
31
+ return null
32
+ }
33
+
34
+ export function gitStatusStamp(projectRoot?: string | null): string {
35
+ const root = findGitRoot(projectRoot)
36
+ if (!root) return "0"
37
+ return [fileStamp(path.join(root, ".git")), fileStamp(path.join(root, ".git", "HEAD")), fileStamp(path.join(root, ".git", "index"))].join("|")
38
+ }
39
+
40
+ function porcelainLetter(xy: string): GitLetter | null {
41
+ if (xy === "??") return "?"
42
+ if (xy === "!!") return null
43
+ const x = xy[0] ?? " "
44
+ const y = xy[1] ?? " "
45
+ if (x === "U" || y === "U" || xy === "AA" || xy === "DD") return "U"
46
+ const rank = (c: string): GitLetter | null => {
47
+ if (c === "D") return "D"
48
+ if (c === "R") return "R"
49
+ if (c === "C") return "C"
50
+ if (c === "A") return "A"
51
+ if (c === "T") return "T"
52
+ if (c === "M") return "M"
53
+ return null
54
+ }
55
+ return rank(x) ?? rank(y)
56
+ }
57
+
58
+ function xIsRename(x: string): boolean {
59
+ return x === "R" || x === "C"
60
+ }
61
+
62
+ /** Parse `git status --porcelain -z` stdout. Exported for tests. */
63
+ export function parsePorcelainZ(buf: string): Map<string, GitLetter> {
64
+ const out = new Map<string, GitLetter>()
65
+ let i = 0
66
+ while (i < buf.length) {
67
+ const xy = buf.slice(i, i + 2)
68
+ if (xy.length < 2) break
69
+ i += 2
70
+ if (buf[i] === " ") i += 1
71
+ const z = buf.indexOf("\0", i)
72
+ if (z < 0) break
73
+ const p1 = buf.slice(i, z).replace(/\\/g, "/")
74
+ i = z + 1
75
+ if (xIsRename(xy[0] ?? "")) {
76
+ const z2 = buf.indexOf("\0", i)
77
+ if (z2 >= 0) i = z2 + 1
78
+ }
79
+ const letter = porcelainLetter(xy)
80
+ if (letter && p1) out.set(p1.toLowerCase(), letter)
81
+ }
82
+ return out
83
+ }
84
+
85
+ export function relToGitRoot(posixPath: string, root: string): string {
86
+ const file = posixPath.replace(/\\/g, "/")
87
+ const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
88
+ const lower = file.toLowerCase()
89
+ const rootLower = base.toLowerCase()
90
+ if (lower.startsWith(`${rootLower}/`)) return lower.slice(rootLower.length + 1)
91
+ return lower.replace(/^\.\//, "")
92
+ }
93
+
94
+ /** Pathspecs for `git status`: repo paths only, deduped, capped. Exported for tests. */
95
+ export function relsFrom(posixPaths: string[], root: string): string[] {
96
+ const base = root.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
97
+ const seen = new Set<string>()
98
+ const out: string[] = []
99
+ for (const p of posixPaths) {
100
+ const file = p.replace(/\\/g, "/")
101
+ // git aborts the whole status with exit 128 on a single outside-repo
102
+ // pathspec — sessions touch temp/config files outside the tree all the time.
103
+ if (!file.toLowerCase().startsWith(`${base}/`)) continue
104
+ const rel = relToGitRoot(p, root)
105
+ if (!rel || seen.has(rel)) continue
106
+ seen.add(rel)
107
+ out.push(rel)
108
+ if (out.length >= GIT_PATH_CAP) break
109
+ }
110
+ return out
111
+ }
112
+
113
+ let cacheKey = ""
114
+ let cacheMarks = new Map<string, GitLetter>()
115
+ let cacheRoot: string | null = null
116
+ let lastPathKey = ""
117
+ let lastSpawn = 0
118
+ let pendingKey = ""
119
+ let spawnGen = 0
120
+ let inFlight = false
121
+
122
+ const listeners = new Set<() => void>()
123
+
124
+ function notify(): void {
125
+ for (const fn of listeners) {
126
+ try {
127
+ fn()
128
+ } catch {
129
+ // sidebar teardown
130
+ }
131
+ }
132
+ }
133
+
134
+ /** Re-render when an async git status lands. Returns unsubscribe. */
135
+ export function onGitMarksChange(fn: () => void): () => void {
136
+ listeners.add(fn)
137
+ return () => {
138
+ listeners.delete(fn)
139
+ }
140
+ }
141
+
142
+ /** Drop cached letters and in-flight spawn accounting (tests). */
143
+ export function resetGitCache(): void {
144
+ spawnGen += 1
145
+ inFlight = false
146
+ cacheKey = ""
147
+ pendingKey = ""
148
+ lastPathKey = ""
149
+ lastSpawn = 0
150
+ cacheRoot = null
151
+ cacheMarks = new Map()
152
+ }
153
+
154
+ function runGit(root: string, rels: string[], key: string, gen: number): void {
155
+ let child: ReturnType<typeof spawn>
156
+ try {
157
+ child = spawn(
158
+ "git",
159
+ ["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--", ...rels],
160
+ { cwd: root, windowsHide: true },
161
+ )
162
+ } catch {
163
+ if (gen === spawnGen) inFlight = false
164
+ return
165
+ }
166
+ let out = ""
167
+ const timer = setTimeout(() => {
168
+ try {
169
+ child.kill()
170
+ } catch {
171
+ // already gone
172
+ }
173
+ }, GIT_TIMEOUT_MS)
174
+ child.stdout?.setEncoding("utf8")
175
+ child.stdout?.on("data", (chunk: string) => {
176
+ out += chunk
177
+ })
178
+ child.stderr?.resume()
179
+ const finish = (ok: boolean) => {
180
+ clearTimeout(timer)
181
+ if (gen !== spawnGen) return
182
+ inFlight = false
183
+ cacheRoot = root
184
+ cacheKey = key
185
+ if (ok) cacheMarks = parsePorcelainZ(out)
186
+ notify()
187
+ }
188
+ child.on("error", () => finish(false))
189
+ child.on("close", (code) => finish(code === 0))
190
+ }
191
+
192
+ /**
193
+ * Last known porcelain letters. Never waits on git — a stale map is returned
194
+ * and a spawn is scheduled when the cache key is cold (2.5 s debounce on index noise).
195
+ */
196
+ export function readGitMarksFor(
197
+ posixPaths: string[],
198
+ projectRoot?: string | null,
199
+ ): {
200
+ root: string | null
201
+ marks: Map<string, GitLetter>
202
+ } {
203
+ const root = findGitRoot(projectRoot)
204
+ if (!root || posixPaths.length === 0) {
205
+ return { root, marks: new Map() }
206
+ }
207
+ const rels = relsFrom(posixPaths, root)
208
+ if (rels.length === 0) {
209
+ return { root, marks: new Map() }
210
+ }
211
+ const pathKey = rels.slice().sort().join("\0")
212
+ const stamp = gitStatusStamp(projectRoot)
213
+ const key = `${root}|${stamp}|${pathKey}`
214
+ cacheRoot = root
215
+ if (key === cacheKey) return { root, marks: cacheMarks }
216
+ if (inFlight && pendingKey === key) return { root, marks: cacheMarks }
217
+
218
+ const now = Date.now()
219
+ if (pathKey === lastPathKey && lastSpawn > 0 && now - lastSpawn < GIT_DEBOUNCE_MS) {
220
+ return { root, marks: cacheMarks }
221
+ }
222
+
223
+ pendingKey = key
224
+ lastPathKey = pathKey
225
+ lastSpawn = now
226
+ spawnGen += 1
227
+ inFlight = true
228
+ runGit(root, rels, key, spawnGen)
229
+ return { root, marks: cacheMarks }
230
+ }
231
+
232
+ export function gitLetterFor(posixPath: string, projectRoot?: string | null): GitLetter | null {
233
+ const { root, marks } = readGitMarksFor([posixPath], projectRoot)
234
+ if (!root || marks.size === 0) return null
235
+ const rel = relToGitRoot(posixPath, root)
236
+ return marks.get(rel) ?? marks.get(posixPath.replace(/\\/g, "/").toLowerCase()) ?? null
237
+ }