tmux-ide 1.2.1 → 2.0.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.
Files changed (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,214 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import ignore from "ignore";
6
+ import {
7
+ buildRootNodes,
8
+ expandNode,
9
+ collapseNode,
10
+ flattenVisibleNodes,
11
+ refreshExpandedNodes,
12
+ } from "./tree-model.ts";
13
+
14
+ let tmpDir: string;
15
+
16
+ beforeEach(() => {
17
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-tree-test-"));
18
+ mkdirSync(join(tmpDir, "src"));
19
+ mkdirSync(join(tmpDir, "src", "lib"));
20
+ writeFileSync(join(tmpDir, "src", "index.ts"), "");
21
+ writeFileSync(join(tmpDir, "src", "lib", "utils.ts"), "");
22
+ mkdirSync(join(tmpDir, "docs"));
23
+ writeFileSync(join(tmpDir, "package.json"), "{}");
24
+ writeFileSync(join(tmpDir, "README.md"), "");
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+
31
+ describe("buildRootNodes", () => {
32
+ it("returns dirs first, then files, alphabetically", () => {
33
+ const ig = ignore();
34
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
35
+ const names = nodes.map((n) => n.entry.name);
36
+ expect(names).toEqual(["docs", "src", "package.json", "README.md"]);
37
+ });
38
+
39
+ it("sets depth to 0 for all root nodes", () => {
40
+ const ig = ignore();
41
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
42
+ expect(nodes.every((n) => n.depth === 0)).toBeTruthy();
43
+ });
44
+
45
+ it("attaches git status from map", () => {
46
+ const ig = ignore();
47
+ const gitMap = new Map([["package.json", "M"]]);
48
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, gitMap, false);
49
+ const pkg = nodes.find((n) => n.entry.name === "package.json");
50
+ expect(pkg?.gitStatus).toBe("M");
51
+ });
52
+
53
+ it("sets gitStatus to null when not in map", () => {
54
+ const ig = ignore();
55
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
56
+ expect(nodes.every((n) => n.gitStatus === null)).toBeTruthy();
57
+ });
58
+
59
+ it("filters hidden files when showHidden is false", () => {
60
+ writeFileSync(join(tmpDir, ".hidden"), "");
61
+ const ig = ignore();
62
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
63
+ expect(!nodes.some((n) => n.entry.name === ".hidden")).toBeTruthy();
64
+ });
65
+
66
+ it("includes hidden files when showHidden is true", () => {
67
+ writeFileSync(join(tmpDir, ".hidden"), "");
68
+ const ig = ignore();
69
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), true);
70
+ expect(nodes.some((n) => n.entry.name === ".hidden")).toBeTruthy();
71
+ });
72
+
73
+ it("uses projectRoot for relative paths when scanning a subdirectory", () => {
74
+ const ig = ignore();
75
+ const nodes = buildRootNodes(join(tmpDir, "src"), tmpDir, ig, new Map(), false);
76
+ const indexNode = nodes.find((n) => n.entry.name === "index.ts");
77
+ expect(indexNode).toBeTruthy();
78
+ expect(indexNode.entry.path).toBe("src/index.ts");
79
+ });
80
+ });
81
+
82
+ describe("expandNode", () => {
83
+ it("populates children with correct depth", () => {
84
+ const ig = ignore();
85
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
86
+ const src = nodes.find((n) => n.entry.name === "src")!;
87
+ expandNode(src, tmpDir, ig, new Map(), false);
88
+ expect(src.expanded).toBe(true);
89
+ expect(src.children.length > 0).toBeTruthy();
90
+ expect(src.children.every((c) => c.depth === 1)).toBeTruthy();
91
+ });
92
+
93
+ it("does not expand files", () => {
94
+ const ig = ignore();
95
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
96
+ const pkg = nodes.find((n) => n.entry.name === "package.json")!;
97
+ expandNode(pkg, tmpDir, ig, new Map(), false);
98
+ expect(pkg.expanded).toBe(false);
99
+ expect(pkg.children.length).toBe(0);
100
+ });
101
+
102
+ it("does not re-expand already expanded node", () => {
103
+ const ig = ignore();
104
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
105
+ const src = nodes.find((n) => n.entry.name === "src")!;
106
+ expandNode(src, tmpDir, ig, new Map(), false);
107
+ const originalChildren = src.children;
108
+ expandNode(src, tmpDir, ig, new Map(), false);
109
+ expect(src.children).toBe(originalChildren);
110
+ });
111
+
112
+ it("shows nested children with incremented depth", () => {
113
+ const ig = ignore();
114
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
115
+ const src = nodes.find((n) => n.entry.name === "src")!;
116
+ expandNode(src, tmpDir, ig, new Map(), false);
117
+ const lib = src.children.find((c) => c.entry.name === "lib")!;
118
+ expandNode(lib, tmpDir, ig, new Map(), false);
119
+ expect(lib.children.every((c) => c.depth === 2)).toBeTruthy();
120
+ });
121
+ });
122
+
123
+ describe("collapseNode", () => {
124
+ it("clears children and sets expanded to false", () => {
125
+ const ig = ignore();
126
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
127
+ const src = nodes.find((n) => n.entry.name === "src")!;
128
+ expandNode(src, tmpDir, ig, new Map(), false);
129
+ expect(src.children.length > 0).toBeTruthy();
130
+ collapseNode(src);
131
+ expect(src.expanded).toBe(false);
132
+ expect(src.children.length).toBe(0);
133
+ });
134
+ });
135
+
136
+ describe("flattenVisibleNodes", () => {
137
+ it("returns only root nodes when nothing is expanded", () => {
138
+ const ig = ignore();
139
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
140
+ const flat = flattenVisibleNodes(nodes);
141
+ expect(flat.length).toBe(nodes.length);
142
+ });
143
+
144
+ it("includes children of expanded nodes", () => {
145
+ const ig = ignore();
146
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
147
+ const src = nodes.find((n) => n.entry.name === "src")!;
148
+ expandNode(src, tmpDir, ig, new Map(), false);
149
+ const flat = flattenVisibleNodes(nodes);
150
+ expect(flat.length > nodes.length).toBeTruthy();
151
+ const srcIdx = flat.indexOf(src);
152
+ expect(srcIdx >= 0).toBeTruthy();
153
+ expect(flat[srcIdx + 1]?.depth).toBe(1);
154
+ });
155
+
156
+ it("returns correct depth-first order with nested expansion", () => {
157
+ const ig = ignore();
158
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
159
+ const src = nodes.find((n) => n.entry.name === "src")!;
160
+ expandNode(src, tmpDir, ig, new Map(), false);
161
+ const lib = src.children.find((c) => c.entry.name === "lib")!;
162
+ expandNode(lib, tmpDir, ig, new Map(), false);
163
+ const flat = flattenVisibleNodes(nodes);
164
+ const names = flat.map((n) => n.entry.name);
165
+ const srcIdx = names.indexOf("src");
166
+ const libIdx = names.indexOf("lib");
167
+ const utilsIdx = names.indexOf("utils.ts");
168
+ expect(srcIdx < libIdx).toBeTruthy();
169
+ expect(libIdx < utilsIdx).toBeTruthy();
170
+ });
171
+ });
172
+
173
+ describe("refreshExpandedNodes", () => {
174
+ it("picks up new files in expanded directories", () => {
175
+ const ig = ignore();
176
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
177
+ const src = nodes.find((n) => n.entry.name === "src")!;
178
+ expandNode(src, tmpDir, ig, new Map(), false);
179
+ const countBefore = src.children.length;
180
+
181
+ writeFileSync(join(tmpDir, "src", "new-file.ts"), "");
182
+
183
+ const refreshed = refreshExpandedNodes(nodes, tmpDir, ig, new Map(), false);
184
+ const refreshedSrc = refreshed.find((n) => n.entry.name === "src")!;
185
+ expect(refreshedSrc.expanded).toBeTruthy();
186
+ expect(refreshedSrc.children.length > countBefore).toBeTruthy();
187
+ expect(refreshedSrc.children.some((c) => c.entry.name === "new-file.ts")).toBeTruthy();
188
+ });
189
+
190
+ it("preserves expanded state of nested directories", () => {
191
+ const ig = ignore();
192
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
193
+ const src = nodes.find((n) => n.entry.name === "src")!;
194
+ expandNode(src, tmpDir, ig, new Map(), false);
195
+ const lib = src.children.find((c) => c.entry.name === "lib")!;
196
+ expandNode(lib, tmpDir, ig, new Map(), false);
197
+
198
+ const refreshed = refreshExpandedNodes(nodes, tmpDir, ig, new Map(), false);
199
+ const refreshedSrc = refreshed.find((n) => n.entry.name === "src")!;
200
+ const refreshedLib = refreshedSrc.children.find((c) => c.entry.name === "lib")!;
201
+ expect(refreshedLib.expanded).toBe(true);
202
+ expect(refreshedLib.children.length > 0).toBeTruthy();
203
+ });
204
+
205
+ it("updates git status on refresh", () => {
206
+ const ig = ignore();
207
+ const gitMap = new Map([["package.json", "M"]]);
208
+ const nodes = buildRootNodes(tmpDir, tmpDir, ig, new Map(), false);
209
+
210
+ const refreshed = refreshExpandedNodes(nodes, tmpDir, ig, gitMap, false);
211
+ const pkg = refreshed.find((n) => n.entry.name === "package.json");
212
+ expect(pkg?.gitStatus).toBe("M");
213
+ });
214
+ });
@@ -0,0 +1,103 @@
1
+ import { readDirectory, type FileEntry, type Ignore } from "../lib/files.ts";
2
+
3
+ export interface TreeNode {
4
+ entry: FileEntry;
5
+ expanded: boolean;
6
+ children: TreeNode[];
7
+ depth: number;
8
+ gitStatus: string | null;
9
+ }
10
+
11
+ export function buildRootNodes(
12
+ scanDir: string,
13
+ projectRoot: string,
14
+ ig: Ignore,
15
+ gitMap: Map<string, string>,
16
+ showHidden: boolean,
17
+ showIgnored: boolean = false,
18
+ ): TreeNode[] {
19
+ return readDirectory(scanDir, projectRoot, ig, showHidden, showIgnored).map((entry) => ({
20
+ entry,
21
+ expanded: false,
22
+ children: [],
23
+ depth: 0,
24
+ gitStatus: gitMap.get(entry.path) ?? null,
25
+ }));
26
+ }
27
+
28
+ export function expandNode(
29
+ node: TreeNode,
30
+ projectRoot: string,
31
+ ig: Ignore,
32
+ gitMap: Map<string, string>,
33
+ showHidden: boolean,
34
+ ): void {
35
+ if (!node.entry.isDir || node.expanded) return;
36
+ node.expanded = true;
37
+ node.children = readDirectory(node.entry.absolutePath, projectRoot, ig, showHidden).map(
38
+ (entry) => ({
39
+ entry,
40
+ expanded: false,
41
+ children: [],
42
+ depth: node.depth + 1,
43
+ gitStatus: gitMap.get(entry.path) ?? null,
44
+ }),
45
+ );
46
+ }
47
+
48
+ export function collapseNode(node: TreeNode): void {
49
+ node.expanded = false;
50
+ node.children = [];
51
+ }
52
+
53
+ export function flattenVisibleNodes(nodes: TreeNode[]): TreeNode[] {
54
+ const result: TreeNode[] = [];
55
+ function walk(list: TreeNode[]) {
56
+ for (const node of list) {
57
+ result.push(node);
58
+ if (node.expanded) walk(node.children);
59
+ }
60
+ }
61
+ walk(nodes);
62
+ return result;
63
+ }
64
+
65
+ export function refreshExpandedNodes(
66
+ nodes: TreeNode[],
67
+ projectRoot: string,
68
+ ig: Ignore,
69
+ gitMap: Map<string, string>,
70
+ showHidden: boolean,
71
+ ): TreeNode[] {
72
+ return nodes.map((node) => {
73
+ const updated: TreeNode = {
74
+ ...node,
75
+ gitStatus: gitMap.get(node.entry.path) ?? null,
76
+ };
77
+ if (node.expanded && node.entry.isDir) {
78
+ const freshChildren = readDirectory(node.entry.absolutePath, projectRoot, ig, showHidden).map(
79
+ (entry) => ({
80
+ entry,
81
+ expanded: false,
82
+ children: [],
83
+ depth: node.depth + 1,
84
+ gitStatus: gitMap.get(entry.path) ?? null,
85
+ }),
86
+ );
87
+ // Preserve expanded state of children that still exist
88
+ const oldByPath = new Map(node.children.map((c) => [c.entry.path, c]));
89
+ updated.children = freshChildren.map((fresh) => {
90
+ const old = oldByPath.get(fresh.entry.path);
91
+ if (old?.expanded && old.entry.isDir) {
92
+ return {
93
+ ...fresh,
94
+ expanded: true,
95
+ children: refreshExpandedNodes([old], projectRoot, ig, gitMap, showHidden)[0]!.children,
96
+ };
97
+ }
98
+ return fresh;
99
+ });
100
+ }
101
+ return updated;
102
+ });
103
+ }
@@ -0,0 +1,156 @@
1
+ import { createMemo, createEffect, For, Show } from "solid-js";
2
+ import { RGBA, type ScrollBoxRenderable } from "@opentui/core";
3
+ import type { TreeNode } from "./tree-model.ts";
4
+ import type { WidgetTheme } from "../lib/theme.ts";
5
+
6
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
7
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
8
+ }
9
+
10
+ const TRANSPARENT = RGBA.fromInts(0, 0, 0, 0);
11
+
12
+ function getStatusLabel(status: string): string {
13
+ switch (status) {
14
+ case "M":
15
+ return " M";
16
+ case "A":
17
+ return " A";
18
+ case "D":
19
+ return " D";
20
+ case "?":
21
+ return " ?";
22
+ default:
23
+ return "";
24
+ }
25
+ }
26
+
27
+ function getStatusColor(
28
+ status: string,
29
+ theme: WidgetTheme,
30
+ ): { r: number; g: number; b: number; a: number } {
31
+ switch (status) {
32
+ case "M":
33
+ return theme.gitModified;
34
+ case "A":
35
+ return theme.gitAdded;
36
+ case "D":
37
+ return theme.gitDeleted;
38
+ case "?":
39
+ return theme.gitUntracked;
40
+ default:
41
+ return theme.fgMuted;
42
+ }
43
+ }
44
+
45
+ function getNameColor(
46
+ name: string,
47
+ isDir: boolean,
48
+ isSelected: boolean,
49
+ isIgnored: boolean,
50
+ theme: WidgetTheme,
51
+ ): { r: number; g: number; b: number; a: number } {
52
+ if (isSelected) return theme.selectedText;
53
+ if (isIgnored) return theme.ignored;
54
+ if (isDir) return theme.dirName;
55
+ if (name.endsWith(".lock") || name.startsWith(".") || name === "LICENSE") {
56
+ return theme.fgMuted;
57
+ }
58
+ return theme.fg;
59
+ }
60
+
61
+ interface FileTreeProps {
62
+ nodes: TreeNode[];
63
+ selected: number;
64
+ theme: WidgetTheme;
65
+ inputMode: "keyboard" | "mouse";
66
+ onSelect: (index: number) => void;
67
+ onActivate: (node: TreeNode) => void;
68
+ onInputModeChange: (mode: "keyboard" | "mouse") => void;
69
+ }
70
+
71
+ export function FileTree(props: FileTreeProps) {
72
+ let scroll: ScrollBoxRenderable | undefined;
73
+
74
+ createEffect(() => {
75
+ const idx = props.selected;
76
+ if (!scroll) return;
77
+ const children = scroll.getChildren();
78
+ const target = children[idx];
79
+ if (!target) return;
80
+ const y = target.y - scroll.y;
81
+ if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1);
82
+ if (y < 0) scroll.scrollBy(y);
83
+ });
84
+
85
+ return (
86
+ <scrollbox
87
+ ref={(r: ScrollBoxRenderable) => (scroll = r)}
88
+ flexGrow={1}
89
+ verticalScrollbarOptions={{
90
+ trackOptions: {
91
+ backgroundColor: toRGBA(props.theme.bg),
92
+ foregroundColor: toRGBA(props.theme.accent),
93
+ },
94
+ }}
95
+ >
96
+ <For each={props.nodes}>
97
+ {(node, index) => {
98
+ const isSelected = createMemo(() => index() === props.selected);
99
+ const icon = node.entry.isDir ? "> " : " ";
100
+ const nameColor = () =>
101
+ getNameColor(
102
+ node.entry.name,
103
+ node.entry.isDir,
104
+ isSelected(),
105
+ node.entry.ignored,
106
+ props.theme,
107
+ );
108
+ const rowBg = () =>
109
+ isSelected()
110
+ ? toRGBA(props.theme.selected)
111
+ : index() % 2 === 1
112
+ ? toRGBA(props.theme.rowAlt)
113
+ : TRANSPARENT;
114
+
115
+ return (
116
+ <box
117
+ id={String(index())}
118
+ backgroundColor={rowBg()}
119
+ flexDirection="row"
120
+ paddingLeft={1}
121
+ paddingRight={1}
122
+ onMouseMove={() => {
123
+ props.onInputModeChange("mouse");
124
+ props.onSelect(index());
125
+ }}
126
+ onMouseDown={() => props.onSelect(index())}
127
+ onMouseUp={() => {
128
+ props.onActivate(node);
129
+ }}
130
+ >
131
+ <text fg={toRGBA(nameColor())} wrapMode="none" flexGrow={1}>
132
+ {icon}
133
+ {node.entry.name}
134
+ {node.entry.isDir ? "/" : ""}
135
+ </text>
136
+ <Show when={node.gitStatus}>
137
+ <text
138
+ fg={toRGBA(getStatusColor(node.gitStatus!, props.theme))}
139
+ flexShrink={0}
140
+ wrapMode="none"
141
+ >
142
+ {getStatusLabel(node.gitStatus!)}
143
+ </text>
144
+ </Show>
145
+ <Show when={isSelected() && !node.entry.isDir}>
146
+ <text fg={toRGBA(props.theme.fgMuted)} flexShrink={0} wrapMode="none">
147
+ {" c: send to claude code"}
148
+ </text>
149
+ </Show>
150
+ </box>
151
+ );
152
+ }}
153
+ </For>
154
+ </scrollbox>
155
+ );
156
+ }
@@ -0,0 +1,116 @@
1
+ import { IdeConfigSchema, type IdeConfig, type Row } from "../../schemas/ide-config.ts";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Config Tree
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface TreeNode {
8
+ path: string[];
9
+ label: string;
10
+ value: string | null;
11
+ depth: number;
12
+ expandable: boolean;
13
+ }
14
+
15
+ function flattenValue(obj: unknown, path: string[], depth: number, nodes: TreeNode[]): void {
16
+ if (Array.isArray(obj)) {
17
+ const label = path[path.length - 1] ?? "";
18
+ nodes.push({ path: [...path], label, value: null, depth, expandable: true });
19
+ for (let i = 0; i < obj.length; i++) {
20
+ flattenValue(obj[i], [...path, String(i)], depth + 1, nodes);
21
+ }
22
+ } else if (obj !== null && typeof obj === "object") {
23
+ const label = path[path.length - 1] ?? "";
24
+ nodes.push({ path: [...path], label, value: null, depth, expandable: true });
25
+ for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
26
+ flattenValue(val, [...path, key], depth + 1, nodes);
27
+ }
28
+ } else {
29
+ const label = path[path.length - 1] ?? "";
30
+ nodes.push({
31
+ path: [...path],
32
+ label,
33
+ value: obj === undefined || obj === null ? null : String(obj),
34
+ depth,
35
+ expandable: false,
36
+ });
37
+ }
38
+ }
39
+
40
+ export function flattenConfigTree(config: IdeConfig): TreeNode[] {
41
+ const nodes: TreeNode[] = [];
42
+ for (const [key, val] of Object.entries(config)) {
43
+ if (val === undefined) continue;
44
+ flattenValue(val, [key], 0, nodes);
45
+ }
46
+ return nodes;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Config Mutations
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export function deepClone<T>(obj: T): T {
54
+ return JSON.parse(JSON.stringify(obj)) as T;
55
+ }
56
+
57
+ export function updateConfigAtPath(config: IdeConfig, path: string[], value: unknown): IdeConfig {
58
+ const cloned = deepClone(config);
59
+ let current: Record<string, unknown> = cloned as unknown as Record<string, unknown>;
60
+ for (let i = 0; i < path.length - 1; i++) {
61
+ const key = path[i]!;
62
+ current = current[key] as Record<string, unknown>;
63
+ }
64
+ const lastKey = path[path.length - 1]!;
65
+ current[lastKey] = value;
66
+ return cloned;
67
+ }
68
+
69
+ export function addPane(config: IdeConfig, rowIdx: number): IdeConfig {
70
+ const cloned = deepClone(config);
71
+ const row = cloned.rows[rowIdx];
72
+ if (!row) return cloned;
73
+ row.panes.push({ title: "New Pane" });
74
+ return cloned;
75
+ }
76
+
77
+ export function removePane(config: IdeConfig, rowIdx: number, paneIdx: number): IdeConfig {
78
+ const cloned = deepClone(config);
79
+ const row = cloned.rows[rowIdx];
80
+ if (!row) return cloned;
81
+ if (row.panes.length <= 1) return cloned;
82
+ row.panes.splice(paneIdx, 1);
83
+ return cloned;
84
+ }
85
+
86
+ export function addRow(config: IdeConfig, size?: string): IdeConfig {
87
+ const cloned = deepClone(config);
88
+ const row: Row = { panes: [{ title: "Shell" }] };
89
+ if (size) row.size = size;
90
+ cloned.rows.push(row);
91
+ return cloned;
92
+ }
93
+
94
+ export function removeRow(config: IdeConfig, rowIdx: number): IdeConfig {
95
+ const cloned = deepClone(config);
96
+ if (cloned.rows.length <= 1) return cloned;
97
+ cloned.rows.splice(rowIdx, 1);
98
+ return cloned;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Validation
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export function validateSetupConfig(
106
+ config: unknown,
107
+ ): { valid: true; config: IdeConfig } | { valid: false; errors: string[] } {
108
+ const result = IdeConfigSchema.safeParse(config);
109
+ if (result.success) {
110
+ return { valid: true, config: result.data };
111
+ }
112
+ return {
113
+ valid: false,
114
+ errors: result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
115
+ };
116
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createIgnoreFilter, readDirectory } from "./files.ts";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-files-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("readDirectory", () => {
18
+ it("returns sorted entries with directories first", () => {
19
+ mkdirSync(join(tmpDir, "beta-dir"));
20
+ mkdirSync(join(tmpDir, "alpha-dir"));
21
+ writeFileSync(join(tmpDir, "zebra.txt"), "");
22
+ writeFileSync(join(tmpDir, "apple.txt"), "");
23
+ const ig = createIgnoreFilter(tmpDir);
24
+ const entries = readDirectory(tmpDir, tmpDir, ig, false);
25
+ const names = entries.map((e) => e.name);
26
+ expect(names).toEqual(["alpha-dir", "beta-dir", "apple.txt", "zebra.txt"]);
27
+ });
28
+
29
+ it("marks directories and files correctly", () => {
30
+ mkdirSync(join(tmpDir, "subdir"));
31
+ writeFileSync(join(tmpDir, "file.txt"), "");
32
+ const ig = createIgnoreFilter(tmpDir);
33
+ const entries = readDirectory(tmpDir, tmpDir, ig, false);
34
+ const dir = entries.find((e) => e.name === "subdir");
35
+ const file = entries.find((e) => e.name === "file.txt");
36
+ expect(dir?.isDir).toBe(true);
37
+ expect(file?.isDir).toBe(false);
38
+ });
39
+
40
+ it("filters ALWAYS_IGNORE entries like node_modules and .git", () => {
41
+ mkdirSync(join(tmpDir, "node_modules"));
42
+ mkdirSync(join(tmpDir, ".git"));
43
+ mkdirSync(join(tmpDir, "src"));
44
+ writeFileSync(join(tmpDir, "file.txt"), "");
45
+ const ig = createIgnoreFilter(tmpDir);
46
+ const entries = readDirectory(tmpDir, tmpDir, ig, false);
47
+ const names = entries.map((e) => e.name);
48
+ expect(!names.includes("node_modules")).toBeTruthy();
49
+ expect(!names.includes(".git")).toBeTruthy();
50
+ expect(names.includes("src")).toBeTruthy();
51
+ expect(names.includes("file.txt")).toBeTruthy();
52
+ });
53
+
54
+ it("respects .gitignore patterns", () => {
55
+ writeFileSync(join(tmpDir, ".gitignore"), "*.log\nbuild/\n");
56
+ writeFileSync(join(tmpDir, "app.log"), "log data");
57
+ writeFileSync(join(tmpDir, "main.ts"), "code");
58
+ mkdirSync(join(tmpDir, "build"));
59
+ const ig = createIgnoreFilter(tmpDir);
60
+ const entries = readDirectory(tmpDir, tmpDir, ig, true); // showHidden to see .gitignore
61
+ const names = entries.map((e) => e.name);
62
+ expect(!names.includes("app.log")).toBeTruthy();
63
+ expect(!names.includes("build")).toBeTruthy();
64
+ expect(names.includes("main.ts")).toBeTruthy();
65
+ });
66
+
67
+ it("hides hidden files when showHidden is false", () => {
68
+ writeFileSync(join(tmpDir, ".hidden"), "");
69
+ writeFileSync(join(tmpDir, "visible.txt"), "");
70
+ const ig = createIgnoreFilter(tmpDir);
71
+ const entries = readDirectory(tmpDir, tmpDir, ig, false);
72
+ const names = entries.map((e) => e.name);
73
+ expect(!names.includes(".hidden")).toBeTruthy();
74
+ expect(names.includes("visible.txt")).toBeTruthy();
75
+ });
76
+
77
+ it("shows hidden files when showHidden is true", () => {
78
+ writeFileSync(join(tmpDir, ".hidden"), "");
79
+ writeFileSync(join(tmpDir, "visible.txt"), "");
80
+ const ig = createIgnoreFilter(tmpDir);
81
+ const entries = readDirectory(tmpDir, tmpDir, ig, true);
82
+ const names = entries.map((e) => e.name);
83
+ expect(names.includes(".hidden")).toBeTruthy();
84
+ expect(names.includes("visible.txt")).toBeTruthy();
85
+ });
86
+
87
+ it("returns empty array for non-existent directory", () => {
88
+ const ig = createIgnoreFilter(tmpDir);
89
+ const entries = readDirectory(join(tmpDir, "nonexistent"), tmpDir, ig, false);
90
+ expect(entries).toEqual([]);
91
+ });
92
+
93
+ it("provides correct relative and absolute paths", () => {
94
+ mkdirSync(join(tmpDir, "src"));
95
+ writeFileSync(join(tmpDir, "src", "file.ts"), "");
96
+ const ig = createIgnoreFilter(tmpDir);
97
+ const entries = readDirectory(join(tmpDir, "src"), tmpDir, ig, false);
98
+ const file = entries.find((e) => e.name === "file.ts");
99
+ expect(file).toBeTruthy();
100
+ expect(file.path).toBe("src/file.ts");
101
+ expect(file.absolutePath).toBe(join(tmpDir, "src", "file.ts"));
102
+ });
103
+ });