@doriandev/devcc 0.1.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.
Files changed (284) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +659 -0
  3. package/bin/devcc.mjs +66 -0
  4. package/dist/devcc/app/App.d.ts +178 -0
  5. package/dist/devcc/app/App.d.ts.map +1 -0
  6. package/dist/devcc/app/App.js +1255 -0
  7. package/dist/devcc/app/App.js.map +1 -0
  8. package/dist/devcc/app/builtinCommands.d.ts +17 -0
  9. package/dist/devcc/app/builtinCommands.d.ts.map +1 -0
  10. package/dist/devcc/app/builtinCommands.js +389 -0
  11. package/dist/devcc/app/builtinCommands.js.map +1 -0
  12. package/dist/devcc/app/keymap.d.ts +21 -0
  13. package/dist/devcc/app/keymap.d.ts.map +1 -0
  14. package/dist/devcc/app/keymap.js +162 -0
  15. package/dist/devcc/app/keymap.js.map +1 -0
  16. package/dist/devcc/app/theme.d.ts +49 -0
  17. package/dist/devcc/app/theme.d.ts.map +1 -0
  18. package/dist/devcc/app/theme.js +68 -0
  19. package/dist/devcc/app/theme.js.map +1 -0
  20. package/dist/devcc/cli.d.ts +3 -0
  21. package/dist/devcc/cli.d.ts.map +1 -0
  22. package/dist/devcc/cli.js +113 -0
  23. package/dist/devcc/cli.js.map +1 -0
  24. package/dist/devcc/components/CommandPalette.d.ts +26 -0
  25. package/dist/devcc/components/CommandPalette.d.ts.map +1 -0
  26. package/dist/devcc/components/CommandPalette.js +113 -0
  27. package/dist/devcc/components/CommandPalette.js.map +1 -0
  28. package/dist/devcc/components/ConfirmDialog.d.ts +31 -0
  29. package/dist/devcc/components/ConfirmDialog.d.ts.map +1 -0
  30. package/dist/devcc/components/ConfirmDialog.js +147 -0
  31. package/dist/devcc/components/ConfirmDialog.js.map +1 -0
  32. package/dist/devcc/components/Footer.d.ts +22 -0
  33. package/dist/devcc/components/Footer.d.ts.map +1 -0
  34. package/dist/devcc/components/Footer.js +72 -0
  35. package/dist/devcc/components/Footer.js.map +1 -0
  36. package/dist/devcc/components/Header.d.ts +12 -0
  37. package/dist/devcc/components/Header.d.ts.map +1 -0
  38. package/dist/devcc/components/Header.js +84 -0
  39. package/dist/devcc/components/Header.js.map +1 -0
  40. package/dist/devcc/components/HelpOverlay.d.ts +18 -0
  41. package/dist/devcc/components/HelpOverlay.d.ts.map +1 -0
  42. package/dist/devcc/components/HelpOverlay.js +48 -0
  43. package/dist/devcc/components/HelpOverlay.js.map +1 -0
  44. package/dist/devcc/components/Overlay.d.ts +31 -0
  45. package/dist/devcc/components/Overlay.d.ts.map +1 -0
  46. package/dist/devcc/components/Overlay.js +60 -0
  47. package/dist/devcc/components/Overlay.js.map +1 -0
  48. package/dist/devcc/components/ProjectPicker.d.ts +26 -0
  49. package/dist/devcc/components/ProjectPicker.d.ts.map +1 -0
  50. package/dist/devcc/components/ProjectPicker.js +114 -0
  51. package/dist/devcc/components/ProjectPicker.js.map +1 -0
  52. package/dist/devcc/components/Sidebar.d.ts +42 -0
  53. package/dist/devcc/components/Sidebar.d.ts.map +1 -0
  54. package/dist/devcc/components/Sidebar.js +127 -0
  55. package/dist/devcc/components/Sidebar.js.map +1 -0
  56. package/dist/devcc/components/StatusBadge.d.ts +16 -0
  57. package/dist/devcc/components/StatusBadge.d.ts.map +1 -0
  58. package/dist/devcc/components/StatusBadge.js +30 -0
  59. package/dist/devcc/components/StatusBadge.js.map +1 -0
  60. package/dist/devcc/components/primitives.d.ts +62 -0
  61. package/dist/devcc/components/primitives.d.ts.map +1 -0
  62. package/dist/devcc/components/primitives.js +169 -0
  63. package/dist/devcc/components/primitives.js.map +1 -0
  64. package/dist/devcc/components/styled.d.ts +16 -0
  65. package/dist/devcc/components/styled.d.ts.map +1 -0
  66. package/dist/devcc/components/styled.js +35 -0
  67. package/dist/devcc/components/styled.js.map +1 -0
  68. package/dist/devcc/core/commands.d.ts +89 -0
  69. package/dist/devcc/core/commands.d.ts.map +1 -0
  70. package/dist/devcc/core/commands.js +68 -0
  71. package/dist/devcc/core/commands.js.map +1 -0
  72. package/dist/devcc/core/config.d.ts +81 -0
  73. package/dist/devcc/core/config.d.ts.map +1 -0
  74. package/dist/devcc/core/config.js +313 -0
  75. package/dist/devcc/core/config.js.map +1 -0
  76. package/dist/devcc/core/events.d.ts +21 -0
  77. package/dist/devcc/core/events.d.ts.map +1 -0
  78. package/dist/devcc/core/events.js +27 -0
  79. package/dist/devcc/core/events.js.map +1 -0
  80. package/dist/devcc/core/state.d.ts +104 -0
  81. package/dist/devcc/core/state.d.ts.map +1 -0
  82. package/dist/devcc/core/state.js +139 -0
  83. package/dist/devcc/core/state.js.map +1 -0
  84. package/dist/devcc/deps/DependencyService.d.ts +50 -0
  85. package/dist/devcc/deps/DependencyService.d.ts.map +1 -0
  86. package/dist/devcc/deps/DependencyService.js +109 -0
  87. package/dist/devcc/deps/DependencyService.js.map +1 -0
  88. package/dist/devcc/deps/parse.d.ts +49 -0
  89. package/dist/devcc/deps/parse.d.ts.map +1 -0
  90. package/dist/devcc/deps/parse.js +158 -0
  91. package/dist/devcc/deps/parse.js.map +1 -0
  92. package/dist/devcc/git/GitService.d.ts +60 -0
  93. package/dist/devcc/git/GitService.d.ts.map +1 -0
  94. package/dist/devcc/git/GitService.js +160 -0
  95. package/dist/devcc/git/GitService.js.map +1 -0
  96. package/dist/devcc/git/parse.d.ts +57 -0
  97. package/dist/devcc/git/parse.d.ts.map +1 -0
  98. package/dist/devcc/git/parse.js +174 -0
  99. package/dist/devcc/git/parse.js.map +1 -0
  100. package/dist/devcc/index.d.ts +37 -0
  101. package/dist/devcc/index.d.ts.map +1 -0
  102. package/dist/devcc/index.js +36 -0
  103. package/dist/devcc/index.js.map +1 -0
  104. package/dist/devcc/logs/LogBuffer.d.ts +86 -0
  105. package/dist/devcc/logs/LogBuffer.d.ts.map +1 -0
  106. package/dist/devcc/logs/LogBuffer.js +200 -0
  107. package/dist/devcc/logs/LogBuffer.js.map +1 -0
  108. package/dist/devcc/logs/merge.d.ts +36 -0
  109. package/dist/devcc/logs/merge.d.ts.map +1 -0
  110. package/dist/devcc/logs/merge.js +71 -0
  111. package/dist/devcc/logs/merge.js.map +1 -0
  112. package/dist/devcc/processes/ManagedProcess.d.ts +129 -0
  113. package/dist/devcc/processes/ManagedProcess.d.ts.map +1 -0
  114. package/dist/devcc/processes/ManagedProcess.js +80 -0
  115. package/dist/devcc/processes/ManagedProcess.js.map +1 -0
  116. package/dist/devcc/processes/ProcessManager.d.ts +112 -0
  117. package/dist/devcc/processes/ProcessManager.d.ts.map +1 -0
  118. package/dist/devcc/processes/ProcessManager.js +525 -0
  119. package/dist/devcc/processes/ProcessManager.js.map +1 -0
  120. package/dist/devcc/processes/graph.d.ts +35 -0
  121. package/dist/devcc/processes/graph.d.ts.map +1 -0
  122. package/dist/devcc/processes/graph.js +68 -0
  123. package/dist/devcc/processes/graph.js.map +1 -0
  124. package/dist/devcc/projects/ProjectRegistry.d.ts +52 -0
  125. package/dist/devcc/projects/ProjectRegistry.d.ts.map +1 -0
  126. package/dist/devcc/projects/ProjectRegistry.js +177 -0
  127. package/dist/devcc/projects/ProjectRegistry.js.map +1 -0
  128. package/dist/devcc/services/ServiceManager.d.ts +48 -0
  129. package/dist/devcc/services/ServiceManager.d.ts.map +1 -0
  130. package/dist/devcc/services/ServiceManager.js +146 -0
  131. package/dist/devcc/services/ServiceManager.js.map +1 -0
  132. package/dist/devcc/services/docker.d.ts +37 -0
  133. package/dist/devcc/services/docker.d.ts.map +1 -0
  134. package/dist/devcc/services/docker.js +179 -0
  135. package/dist/devcc/services/docker.js.map +1 -0
  136. package/dist/devcc/services/node.d.ts +40 -0
  137. package/dist/devcc/services/node.d.ts.map +1 -0
  138. package/dist/devcc/services/node.js +107 -0
  139. package/dist/devcc/services/node.js.map +1 -0
  140. package/dist/devcc/services/ollama.d.ts +27 -0
  141. package/dist/devcc/services/ollama.d.ts.map +1 -0
  142. package/dist/devcc/services/ollama.js +66 -0
  143. package/dist/devcc/services/ollama.js.map +1 -0
  144. package/dist/devcc/services/postgres.d.ts +30 -0
  145. package/dist/devcc/services/postgres.d.ts.map +1 -0
  146. package/dist/devcc/services/postgres.js +47 -0
  147. package/dist/devcc/services/postgres.js.map +1 -0
  148. package/dist/devcc/services/redis.d.ts +20 -0
  149. package/dist/devcc/services/redis.d.ts.map +1 -0
  150. package/dist/devcc/services/redis.js +39 -0
  151. package/dist/devcc/services/redis.js.map +1 -0
  152. package/dist/devcc/services/types.d.ts +51 -0
  153. package/dist/devcc/services/types.d.ts.map +1 -0
  154. package/dist/devcc/services/types.js +10 -0
  155. package/dist/devcc/services/types.js.map +1 -0
  156. package/dist/devcc/system/SystemMonitor.d.ts +87 -0
  157. package/dist/devcc/system/SystemMonitor.d.ts.map +1 -0
  158. package/dist/devcc/system/SystemMonitor.js +142 -0
  159. package/dist/devcc/system/SystemMonitor.js.map +1 -0
  160. package/dist/devcc/utils/errors.d.ts +31 -0
  161. package/dist/devcc/utils/errors.d.ts.map +1 -0
  162. package/dist/devcc/utils/errors.js +52 -0
  163. package/dist/devcc/utils/errors.js.map +1 -0
  164. package/dist/devcc/utils/exec.d.ts +41 -0
  165. package/dist/devcc/utils/exec.d.ts.map +1 -0
  166. package/dist/devcc/utils/exec.js +112 -0
  167. package/dist/devcc/utils/exec.js.map +1 -0
  168. package/dist/devcc/utils/format.d.ts +18 -0
  169. package/dist/devcc/utils/format.d.ts.map +1 -0
  170. package/dist/devcc/utils/format.js +85 -0
  171. package/dist/devcc/utils/format.js.map +1 -0
  172. package/dist/devcc/utils/fuzzy.d.ts +12 -0
  173. package/dist/devcc/utils/fuzzy.d.ts.map +1 -0
  174. package/dist/devcc/utils/fuzzy.js +20 -0
  175. package/dist/devcc/utils/fuzzy.js.map +1 -0
  176. package/dist/devcc/utils/logger.d.ts +41 -0
  177. package/dist/devcc/utils/logger.d.ts.map +1 -0
  178. package/dist/devcc/utils/logger.js +117 -0
  179. package/dist/devcc/utils/logger.js.map +1 -0
  180. package/dist/devcc/utils/packageManager.d.ts +39 -0
  181. package/dist/devcc/utils/packageManager.d.ts.map +1 -0
  182. package/dist/devcc/utils/packageManager.js +85 -0
  183. package/dist/devcc/utils/packageManager.js.map +1 -0
  184. package/dist/devcc/utils/paths.d.ts +12 -0
  185. package/dist/devcc/utils/paths.d.ts.map +1 -0
  186. package/dist/devcc/utils/paths.js +39 -0
  187. package/dist/devcc/utils/paths.js.map +1 -0
  188. package/dist/devcc/views/DependenciesView.d.ts +34 -0
  189. package/dist/devcc/views/DependenciesView.d.ts.map +1 -0
  190. package/dist/devcc/views/DependenciesView.js +170 -0
  191. package/dist/devcc/views/DependenciesView.js.map +1 -0
  192. package/dist/devcc/views/GitView.d.ts +29 -0
  193. package/dist/devcc/views/GitView.d.ts.map +1 -0
  194. package/dist/devcc/views/GitView.js +226 -0
  195. package/dist/devcc/views/GitView.js.map +1 -0
  196. package/dist/devcc/views/LogsView.d.ts +39 -0
  197. package/dist/devcc/views/LogsView.d.ts.map +1 -0
  198. package/dist/devcc/views/LogsView.js +249 -0
  199. package/dist/devcc/views/LogsView.js.map +1 -0
  200. package/dist/devcc/views/OverviewView.d.ts +16 -0
  201. package/dist/devcc/views/OverviewView.d.ts.map +1 -0
  202. package/dist/devcc/views/OverviewView.js +135 -0
  203. package/dist/devcc/views/OverviewView.js.map +1 -0
  204. package/dist/devcc/views/ProcessesView.d.ts +26 -0
  205. package/dist/devcc/views/ProcessesView.d.ts.map +1 -0
  206. package/dist/devcc/views/ProcessesView.js +161 -0
  207. package/dist/devcc/views/ProcessesView.js.map +1 -0
  208. package/dist/devcc/views/ScriptsView.d.ts +30 -0
  209. package/dist/devcc/views/ScriptsView.d.ts.map +1 -0
  210. package/dist/devcc/views/ScriptsView.js +168 -0
  211. package/dist/devcc/views/ScriptsView.js.map +1 -0
  212. package/dist/devcc/views/ServicesView.d.ts +28 -0
  213. package/dist/devcc/views/ServicesView.d.ts.map +1 -0
  214. package/dist/devcc/views/ServicesView.js +153 -0
  215. package/dist/devcc/views/ServicesView.js.map +1 -0
  216. package/dist/devcc/views/SettingsView.d.ts +20 -0
  217. package/dist/devcc/views/SettingsView.d.ts.map +1 -0
  218. package/dist/devcc/views/SettingsView.js +114 -0
  219. package/dist/devcc/views/SettingsView.js.map +1 -0
  220. package/dist/devcc/views/SystemView.d.ts +13 -0
  221. package/dist/devcc/views/SystemView.d.ts.map +1 -0
  222. package/dist/devcc/views/SystemView.js +82 -0
  223. package/dist/devcc/views/SystemView.js.map +1 -0
  224. package/dist/devcc/views/View.d.ts +35 -0
  225. package/dist/devcc/views/View.d.ts.map +1 -0
  226. package/dist/devcc/views/View.js +42 -0
  227. package/dist/devcc/views/View.js.map +1 -0
  228. package/package.json +79 -0
  229. package/src/devcc/app/App.ts +1463 -0
  230. package/src/devcc/app/builtinCommands.ts +429 -0
  231. package/src/devcc/app/keymap.ts +167 -0
  232. package/src/devcc/app/theme.ts +83 -0
  233. package/src/devcc/cli.ts +122 -0
  234. package/src/devcc/components/CommandPalette.ts +136 -0
  235. package/src/devcc/components/ConfirmDialog.ts +174 -0
  236. package/src/devcc/components/Footer.ts +85 -0
  237. package/src/devcc/components/Header.ts +93 -0
  238. package/src/devcc/components/HelpOverlay.ts +62 -0
  239. package/src/devcc/components/Overlay.ts +91 -0
  240. package/src/devcc/components/ProjectPicker.ts +138 -0
  241. package/src/devcc/components/Sidebar.ts +163 -0
  242. package/src/devcc/components/StatusBadge.ts +36 -0
  243. package/src/devcc/components/primitives.ts +213 -0
  244. package/src/devcc/components/styled.ts +44 -0
  245. package/src/devcc/core/commands.ts +140 -0
  246. package/src/devcc/core/config.ts +405 -0
  247. package/src/devcc/core/events.ts +41 -0
  248. package/src/devcc/core/state.ts +236 -0
  249. package/src/devcc/deps/DependencyService.ts +152 -0
  250. package/src/devcc/deps/parse.ts +210 -0
  251. package/src/devcc/git/GitService.ts +205 -0
  252. package/src/devcc/git/parse.ts +203 -0
  253. package/src/devcc/index.ts +115 -0
  254. package/src/devcc/logs/LogBuffer.ts +237 -0
  255. package/src/devcc/logs/merge.ts +87 -0
  256. package/src/devcc/processes/ManagedProcess.ts +195 -0
  257. package/src/devcc/processes/ProcessManager.ts +625 -0
  258. package/src/devcc/processes/graph.ts +98 -0
  259. package/src/devcc/projects/ProjectRegistry.ts +219 -0
  260. package/src/devcc/services/ServiceManager.ts +180 -0
  261. package/src/devcc/services/docker.ts +220 -0
  262. package/src/devcc/services/node.ts +137 -0
  263. package/src/devcc/services/ollama.ts +80 -0
  264. package/src/devcc/services/postgres.ts +57 -0
  265. package/src/devcc/services/redis.ts +47 -0
  266. package/src/devcc/services/types.ts +59 -0
  267. package/src/devcc/system/SystemMonitor.ts +207 -0
  268. package/src/devcc/utils/errors.ts +64 -0
  269. package/src/devcc/utils/exec.ts +165 -0
  270. package/src/devcc/utils/format.ts +83 -0
  271. package/src/devcc/utils/fuzzy.ts +44 -0
  272. package/src/devcc/utils/logger.ts +140 -0
  273. package/src/devcc/utils/packageManager.ts +112 -0
  274. package/src/devcc/utils/paths.ts +39 -0
  275. package/src/devcc/views/DependenciesView.ts +206 -0
  276. package/src/devcc/views/GitView.ts +259 -0
  277. package/src/devcc/views/LogsView.ts +300 -0
  278. package/src/devcc/views/OverviewView.ts +187 -0
  279. package/src/devcc/views/ProcessesView.ts +177 -0
  280. package/src/devcc/views/ScriptsView.ts +187 -0
  281. package/src/devcc/views/ServicesView.ts +188 -0
  282. package/src/devcc/views/SettingsView.ts +146 -0
  283. package/src/devcc/views/SystemView.ts +110 -0
  284. package/src/devcc/views/View.ts +63 -0
@@ -0,0 +1,1463 @@
1
+ import { BoxRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core"
2
+ import { existsSync } from "node:fs"
3
+ import { basename, join, resolve } from "node:path"
4
+ import { ConfirmDialog, PromptDialog } from "../components/ConfirmDialog.js"
5
+ import { Footer, type KeyHint } from "../components/Footer.js"
6
+ import { Header } from "../components/Header.js"
7
+ import { HelpOverlay } from "../components/HelpOverlay.js"
8
+ import type { Overlay } from "../components/Overlay.js"
9
+ import { Sidebar } from "../components/Sidebar.js"
10
+ import { CommandPalette } from "../components/CommandPalette.js"
11
+ import { ProjectPicker } from "../components/ProjectPicker.js"
12
+ import { CommandRegistry, type CommandContext } from "../core/commands.js"
13
+ import { loadConfig, type CommandConfigEntry, type ResolvedConfig } from "../core/config.js"
14
+ import { Subscriptions } from "../core/events.js"
15
+ import {
16
+ createInitialSelection,
17
+ createInitialState,
18
+ NARROW_WIDTH,
19
+ setSelection,
20
+ shallowArrayEqual,
21
+ Store,
22
+ updateUi,
23
+ VIEW_IDS,
24
+ type AppState,
25
+ type Notification,
26
+ type NotificationLevel,
27
+ type ProjectState,
28
+ type ScriptRun,
29
+ type ViewId,
30
+ } from "../core/state.js"
31
+ import { DependencyService, updateArgs } from "../deps/DependencyService.js"
32
+ import { GitService } from "../git/GitService.js"
33
+ import {
34
+ buildProjectList,
35
+ defaultSearchDirs,
36
+ discoverProjects,
37
+ readRecents,
38
+ recordRecent,
39
+ type ProjectEntry,
40
+ } from "../projects/ProjectRegistry.js"
41
+ import { LogStore, MAX_LOG_LINES } from "../logs/LogBuffer.js"
42
+ import { ALL_SOURCES } from "../logs/merge.js"
43
+ import { isActive } from "../processes/ManagedProcess.js"
44
+ import { ProcessManager } from "../processes/ProcessManager.js"
45
+ import { DockerService } from "../services/docker.js"
46
+ import { detectNodeServices, PortService } from "../services/node.js"
47
+ import { OllamaService } from "../services/ollama.js"
48
+ import { PostgresService } from "../services/postgres.js"
49
+ import { RedisService } from "../services/redis.js"
50
+ import { ServiceManager } from "../services/ServiceManager.js"
51
+ import { SystemMonitor } from "../system/SystemMonitor.js"
52
+ import { errorMessage, toDevccError } from "../utils/errors.js"
53
+ import { createLogger, type Logger } from "../utils/logger.js"
54
+ import { detectPackageManager, readPackageJson, readScripts, runScriptArgs } from "../utils/packageManager.js"
55
+ import { fuzzyFilter } from "../utils/fuzzy.js"
56
+ import { findProjectRoot } from "../utils/paths.js"
57
+ import { DependenciesView } from "../views/DependenciesView.js"
58
+ import { GitView } from "../views/GitView.js"
59
+ import { LogsView } from "../views/LogsView.js"
60
+ import { OverviewView } from "../views/OverviewView.js"
61
+ import { ProcessesView } from "../views/ProcessesView.js"
62
+ import { ScriptsView, scriptProcessId } from "../views/ScriptsView.js"
63
+ import { ServicesView } from "../views/ServicesView.js"
64
+ import { SettingsView } from "../views/SettingsView.js"
65
+ import { SystemView } from "../views/SystemView.js"
66
+ import type { View, ViewDeps } from "../views/View.js"
67
+ import { createBuiltinCommands, createConfigCommands, createScriptCommands } from "./builtinCommands.js"
68
+ import { HELP_SECTIONS, VIEW_SHORTCUTS } from "./keymap.js"
69
+ import { theme } from "./theme.js"
70
+
71
+ export interface AppOptions {
72
+ /** Directory to treat as the project. Defaults to the detected project root. */
73
+ cwd?: string
74
+ /** Injected by tests; production creates its own renderer. */
75
+ renderer?: CliRenderer
76
+ }
77
+
78
+ const LOG_UPDATE_THROTTLE_MS = 80
79
+
80
+ /**
81
+ * The application shell.
82
+ *
83
+ * Owns the renderer, the long-lived services and the single source of truth
84
+ * (`Store`). Backends emit events, the shell folds them into state, and views
85
+ * react to the slices they care about - views never call a backend's internals
86
+ * or poll for changes.
87
+ */
88
+ /**
89
+ * Everything scoped to one project.
90
+ *
91
+ * Switching projects means disposing one of these and building another; the
92
+ * renderer, the views and the store survive, so the UI never blinks.
93
+ */
94
+ interface ProjectSession {
95
+ rootDir: string
96
+ config: ResolvedConfig
97
+ logger: Logger
98
+ logs: LogStore
99
+ processes: ProcessManager
100
+ services: ServiceManager
101
+ git: GitService
102
+ system: SystemMonitor
103
+ deps: DependencyService
104
+ project: ProjectState
105
+ scripts: { name: string; command: string }[]
106
+ /** Script runs belong to the project they ran in, not to the store. */
107
+ scriptRuns: Record<string, ScriptRun>
108
+ /** Backend -> store wiring, disposed when the project is closed. */
109
+ wiring: Subscriptions
110
+ /** Pollers that only run while this project is the active one. */
111
+ timers: NodeJS.Timeout[]
112
+ }
113
+
114
+ export class App {
115
+ private renderer!: CliRenderer
116
+ /** Torn down only when the app exits. */
117
+ private readonly subscriptions = new Subscriptions()
118
+ /** App-level timers (clock, uptime tick). */
119
+ private timers: NodeJS.Timeout[] = []
120
+ private readonly views = new Map<ViewId, View>()
121
+
122
+ private store!: Store<AppState>
123
+ /** Every open project, keyed by root directory. All keep their processes alive. */
124
+ private readonly sessions = new Map<string, ProjectSession>()
125
+ /** The project the views are showing. */
126
+ private session!: ProjectSession
127
+ /** Stable across project switches; repopulated per session. */
128
+ private readonly commands = new CommandRegistry()
129
+ private context!: CommandContext
130
+
131
+ // Session services are reached through getters so the ~60 call sites below
132
+ // keep working unchanged when the session is swapped out.
133
+ private get logger(): Logger {
134
+ return this.session.logger
135
+ }
136
+ private get logs(): LogStore {
137
+ return this.session.logs
138
+ }
139
+ private get processes(): ProcessManager {
140
+ return this.session.processes
141
+ }
142
+ private get services(): ServiceManager {
143
+ return this.session.services
144
+ }
145
+ private get git(): GitService {
146
+ return this.session.git
147
+ }
148
+ private get system(): SystemMonitor {
149
+ return this.session.system
150
+ }
151
+ private get deps(): DependencyService {
152
+ return this.session.deps
153
+ }
154
+ private get config(): ResolvedConfig {
155
+ return this.session.config
156
+ }
157
+
158
+ private header!: Header
159
+ private footer!: Footer
160
+ private sidebar!: Sidebar
161
+ private contentBox!: BoxRenderable
162
+ private bodyBox!: BoxRenderable
163
+ private palette!: CommandPalette
164
+ private projectPicker!: ProjectPicker
165
+ private confirmDialog!: ConfirmDialog
166
+ private promptDialog!: PromptDialog
167
+ private helpOverlay!: HelpOverlay
168
+
169
+ private activeView: ViewId = "overview"
170
+ private viewDirty = false
171
+ private logUpdateTimer: NodeJS.Timeout | null = null
172
+ private notificationSeq = 0
173
+ private switching = false
174
+ private shuttingDown = false
175
+ private exitResolver: (() => void) | null = null
176
+
177
+ constructor(private readonly options: AppOptions = {}) {}
178
+
179
+ /**
180
+ * Bootstrap services, build the UI and start background polling.
181
+ * Returns as soon as the first frame is ready; slower work continues behind it.
182
+ */
183
+ async start(): Promise<void> {
184
+ await this.bootstrap()
185
+ await this.mount()
186
+ this.startAppTimers()
187
+ this.startSessionBackground(this.session)
188
+ }
189
+
190
+ /** Boot the dashboard and resolve when the user quits. */
191
+ async run(): Promise<void> {
192
+ await this.start()
193
+ return new Promise<void>((resolve) => {
194
+ this.exitResolver = resolve
195
+ })
196
+ }
197
+
198
+ // ---------------------------------------------------------------------
199
+ // Bootstrap
200
+ // ---------------------------------------------------------------------
201
+
202
+ private async bootstrap(): Promise<void> {
203
+ const rootDir = this.options.cwd ?? findProjectRoot()
204
+ this.session = await this.createSession(rootDir)
205
+ this.sessions.set(this.session.rootDir, this.session)
206
+
207
+ this.store = new Store<AppState>(createInitialState(this.session.project, 80, 24))
208
+ this.store.patch({ scripts: { available: this.session.scripts, runs: {} } })
209
+ this.context = this.createCommandContext()
210
+
211
+ this.registerSessionCommands()
212
+ this.reportConfigIssues()
213
+ }
214
+
215
+ /**
216
+ * Build every project-scoped service for `rootDir`.
217
+ *
218
+ * Nothing here touches the renderer or the store, which is what makes it
219
+ * reusable for switching projects at runtime.
220
+ */
221
+ private async createSession(rootDir: string): Promise<ProjectSession> {
222
+ const packageJson = readPackageJson(rootDir)
223
+ const packageManager = detectPackageManager({
224
+ rootDir,
225
+ packageJson,
226
+ userAgent: process.env.npm_config_user_agent,
227
+ })
228
+
229
+ const loaded = await loadConfig(rootDir)
230
+ const config = loaded.config
231
+
232
+ const logger = createLogger(rootDir, config.logLevel)
233
+ logger.log("info", "app", "opening project", { rootDir, packageManager: packageManager.manager })
234
+
235
+ const logs = new LogStore(config.maxLogLines ?? MAX_LOG_LINES)
236
+ const system = new SystemMonitor({ rootDir, intervalMs: config.refresh.system })
237
+
238
+ const processes = new ProcessManager({ logs, logger: logger.child("processes"), rootDir })
239
+ processes.registerAll(config.processes)
240
+
241
+ const services = new ServiceManager({
242
+ logger: logger.child("services"),
243
+ intervalMs: config.refresh.services,
244
+ })
245
+ services.registerAll([
246
+ new DockerService({ rootDir }),
247
+ new PostgresService(),
248
+ new RedisService(),
249
+ new OllamaService(),
250
+ ...detectNodeServices(packageJson),
251
+ ...config.services.map(
252
+ (entry) =>
253
+ new PortService({
254
+ id: entry.id,
255
+ name: entry.name,
256
+ port: entry.port,
257
+ host: entry.host,
258
+ healthPath: entry.healthPath,
259
+ category: entry.category,
260
+ }),
261
+ ),
262
+ ])
263
+
264
+ const git = new GitService({ rootDir, logger: logger.child("git") })
265
+ const deps = new DependencyService({
266
+ rootDir,
267
+ manager: packageManager.manager,
268
+ logger: logger.child("deps"),
269
+ })
270
+
271
+ const project: ProjectState = {
272
+ name: config.name ?? (typeof packageJson?.name === "string" ? packageJson.name : basename(rootDir)),
273
+ rootDir,
274
+ packageManager,
275
+ configPath: loaded.filePath,
276
+ configIssues: loaded.issues,
277
+ host: system.getHostInfo(),
278
+ tools: { git: null, docker: null, packageManager: null },
279
+ }
280
+
281
+ const recentError = recordRecent(rootDir)
282
+ if (recentError) logger.log("debug", "app", "could not record recent project", recentError)
283
+
284
+ return {
285
+ rootDir,
286
+ config,
287
+ logger,
288
+ logs,
289
+ processes,
290
+ services,
291
+ git,
292
+ system,
293
+ deps,
294
+ project,
295
+ scripts: readScripts(packageJson),
296
+ scriptRuns: {},
297
+ wiring: new Subscriptions(),
298
+ timers: [],
299
+ }
300
+ }
301
+
302
+ /** Rebuild the command registry for the current session. */
303
+ private registerSessionCommands(): void {
304
+ this.commands.clear()
305
+ this.commands.registerAll(createBuiltinCommands())
306
+ this.commands.registerAll(createScriptCommands(this.session.scripts))
307
+ this.commands.registerAll(
308
+ createConfigCommands(this.config.commands, (entry) => this.runConfigCommand(entry)),
309
+ )
310
+ }
311
+
312
+ private reportConfigIssues(): void {
313
+ for (const issue of this.session.project.configIssues) {
314
+ this.pushNotification("warn", `Config: ${issue}`)
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------
319
+ // UI construction
320
+ // ---------------------------------------------------------------------
321
+
322
+ private async mount(): Promise<void> {
323
+ this.renderer =
324
+ this.options.renderer ??
325
+ (await createCliRenderer({
326
+ // Ctrl+C is handled by the app so processes get a chance to stop.
327
+ exitOnCtrlC: false,
328
+ targetFps: 30,
329
+ backgroundColor: theme.background,
330
+ }))
331
+
332
+ const root = new BoxRenderable(this.renderer, {
333
+ width: "100%",
334
+ height: "100%",
335
+ flexDirection: "column",
336
+ backgroundColor: theme.background,
337
+ })
338
+ this.renderer.root.add(root)
339
+
340
+ this.header = new Header(this.renderer)
341
+ this.footer = new Footer(this.renderer)
342
+ this.sidebar = new Sidebar(this.renderer)
343
+
344
+ this.bodyBox = new BoxRenderable(this.renderer, {
345
+ flexGrow: 1,
346
+ flexDirection: "row",
347
+ backgroundColor: "transparent",
348
+ })
349
+ this.contentBox = new BoxRenderable(this.renderer, {
350
+ flexGrow: 1,
351
+ flexDirection: "column",
352
+ backgroundColor: "transparent",
353
+ })
354
+
355
+ this.bodyBox.add(this.sidebar.box)
356
+ this.bodyBox.add(this.contentBox)
357
+
358
+ root.add(this.header.box)
359
+ root.add(this.bodyBox)
360
+ root.add(this.footer.box)
361
+
362
+ const deps: ViewDeps = { ctx: this.renderer, app: this.context, commands: this.commands }
363
+ const views: View[] = [
364
+ new OverviewView(deps),
365
+ new ProcessesView(deps),
366
+ new ServicesView(deps),
367
+ new ScriptsView(deps),
368
+ new GitView(deps),
369
+ new LogsView(deps),
370
+ new DependenciesView(deps),
371
+ new SystemView(deps),
372
+ new SettingsView(deps),
373
+ ]
374
+ for (const view of views) {
375
+ this.views.set(view.id, view)
376
+ this.contentBox.add(view.box)
377
+ }
378
+
379
+ this.palette = new CommandPalette(this.renderer)
380
+ this.projectPicker = new ProjectPicker(this.renderer)
381
+ this.confirmDialog = new ConfirmDialog(this.renderer)
382
+ this.promptDialog = new PromptDialog(this.renderer)
383
+ this.helpOverlay = new HelpOverlay(this.renderer, HELP_SECTIONS)
384
+ for (const overlay of this.overlays()) {
385
+ this.renderer.root.add(overlay.box)
386
+ }
387
+
388
+ this.handleResize(this.renderer.width, this.renderer.height)
389
+ this.setView("overview")
390
+ this.wireEvents()
391
+ this.renderAll()
392
+ }
393
+
394
+ // ---------------------------------------------------------------------
395
+ // Event wiring
396
+ // ---------------------------------------------------------------------
397
+
398
+ private wireEvents(): void {
399
+ // --- Renderer -------------------------------------------------------
400
+ const onResize = () => this.handleResize(this.renderer.width, this.renderer.height)
401
+ this.renderer.on("resize", onResize)
402
+ this.subscriptions.add(() => this.renderer.off("resize", onResize))
403
+
404
+ const onKey = (key: KeyEvent) => this.handleKey(key)
405
+ this.renderer.keyInput.on("keypress", onKey)
406
+ this.subscriptions.add(() => this.renderer.keyInput.off("keypress", onKey))
407
+
408
+ this.wireSession(this.session)
409
+ this.wireStoreToUi()
410
+
411
+ // --- Signals ---------------------------------------------------------
412
+ const onSignal = (signal: NodeJS.Signals) => {
413
+ this.logger.log("info", "app", "received signal", { signal })
414
+ void this.quit()
415
+ }
416
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
417
+ process.on(signal, onSignal)
418
+ this.subscriptions.add(() => process.off(signal, onSignal))
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Subscribe to one session's backends.
424
+ *
425
+ * Every open project is wired, so background projects keep tracking their
426
+ * processes and capturing logs. Only the *active* project mirrors its state
427
+ * into the store; the others surface nothing but notifications, prefixed
428
+ * with the project name so you know where a crash happened.
429
+ */
430
+ private wireSession(session: ProjectSession): void {
431
+ const { store } = this
432
+ const { wiring, processes, services, git, system, logs, deps } = session
433
+ const active = () => this.session === session
434
+ const notify = (level: NotificationLevel, message: string, detail?: string) =>
435
+ this.pushNotification(level, active() ? message : `[${session.project.name}] ${message}`, detail)
436
+
437
+ const syncProcesses = () => {
438
+ if (active()) store.patch({ processes: processes.list() })
439
+ this.refreshSidebarProjects()
440
+ }
441
+ processes.on("process:changed", syncProcesses)
442
+ processes.on("process:list", syncProcesses)
443
+ processes.on("process:exit", (process) => {
444
+ this.finishScriptRun(session, process.id, process.exitCode ?? 0)
445
+ syncProcesses()
446
+ })
447
+ processes.on("process:error", (error) => notify("error", error.message, error.detail))
448
+ processes.on("process:retry", (managed, delayMs) => {
449
+ notify(
450
+ "warn",
451
+ `${managed.name} crashed; restarting in ${Math.round(delayMs / 100) / 10}s (attempt ${managed.crashRestarts})`,
452
+ )
453
+ syncProcesses()
454
+ })
455
+ processes.on("process:ready", (managed) => {
456
+ if (managed.readinessTimedOut)
457
+ notify("warn", `${managed.name}: readiness check timed out; reported as running`)
458
+ })
459
+ wiring.add(() => processes.removeAllListeners())
460
+
461
+ services.on("service:list", (list) => {
462
+ if (active()) store.patch({ services: list })
463
+ })
464
+ services.on("service:error", (error) => notify("error", error.message, error.detail))
465
+ wiring.add(() => services.removeAllListeners())
466
+
467
+ git.on("git:changed", (snapshot) => {
468
+ if (active()) store.patch({ git: snapshot })
469
+ })
470
+ git.on("git:error", (error) => notify("error", error.message, error.detail))
471
+ wiring.add(() => git.removeAllListeners())
472
+
473
+ system.on("system:metrics", (metrics) => {
474
+ if (active()) store.patch({ system: metrics })
475
+ })
476
+ system.on("system:tools", (tools) => {
477
+ session.project = { ...session.project, tools }
478
+ if (active()) store.setState((state) => ({ ...state, project: { ...state.project, tools } }))
479
+ })
480
+ wiring.add(() => system.removeAllListeners())
481
+
482
+ logs.on("log:sources", (sources) => {
483
+ if (!active()) return
484
+ store.setState((state) => ({
485
+ ...state,
486
+ logSources: sources,
487
+ ui: { ...state.ui, logSource: state.ui.logSource ?? sources[0]?.id ?? null },
488
+ }))
489
+ })
490
+ logs.on("log:append", (id) => {
491
+ if (!active() || this.activeView !== "logs") return
492
+ const current = store.getState().ui.logSource
493
+ if (current === id || current === ALL_SOURCES) this.scheduleLogUpdate()
494
+ })
495
+ logs.on("log:clear", () => {
496
+ if (active()) this.markViewDirty()
497
+ })
498
+ wiring.add(() => logs.removeAllListeners())
499
+
500
+ deps.on("deps:changed", (snapshot) => {
501
+ if (active()) store.patch({ dependencies: snapshot })
502
+ })
503
+ deps.on("deps:error", (error) => notify("error", error.message, error.detail))
504
+ wiring.add(() => deps.removeAllListeners())
505
+ }
506
+
507
+ /** Store -> UI watchers. App-level: they outlive any single project. */
508
+ private wireStoreToUi(): void {
509
+ const { store } = this
510
+ this.subscriptions.add(
511
+ store.watch(
512
+ (state) => state.ui.view,
513
+ (view) => this.setView(view),
514
+ ),
515
+ )
516
+ this.subscriptions.add(
517
+ store.watch(
518
+ (state) => state.ui.clock,
519
+ () => this.header.update(store.getState()),
520
+ ),
521
+ )
522
+ this.subscriptions.add(
523
+ store.watch(
524
+ (state) => state.ui.sidebarVisible,
525
+ (visible) => {
526
+ this.sidebar.box.visible = visible
527
+ },
528
+ ),
529
+ )
530
+ this.subscriptions.add(
531
+ store.watch(
532
+ (state) => state.notifications[0],
533
+ (notification) => this.showNotification(notification),
534
+ ),
535
+ )
536
+
537
+ // Domain slices that force the active view to re-render.
538
+ const markDirty = () => this.markViewDirty()
539
+ this.subscriptions.add(store.watch((state) => state.processes, markDirty, shallowArrayEqual))
540
+ this.subscriptions.add(store.watch((state) => state.services, markDirty, shallowArrayEqual))
541
+ this.subscriptions.add(store.watch((state) => state.git, markDirty))
542
+ this.subscriptions.add(store.watch((state) => state.dependencies, markDirty))
543
+ this.subscriptions.add(store.watch((state) => state.projects, markDirty, shallowArrayEqual))
544
+ this.subscriptions.add(store.watch((state) => state.system, markDirty))
545
+ this.subscriptions.add(store.watch((state) => state.scripts, markDirty))
546
+ this.subscriptions.add(store.watch((state) => state.logSources, markDirty, shallowArrayEqual))
547
+ this.subscriptions.add(store.watch((state) => state.notifications, markDirty, shallowArrayEqual))
548
+ this.subscriptions.add(store.watch((state) => state.ui.selection, markDirty))
549
+ this.subscriptions.add(store.watch((state) => state.ui.logSource, markDirty))
550
+ this.subscriptions.add(store.watch((state) => state.ui.logFollow, markDirty))
551
+ this.subscriptions.add(store.watch((state) => state.ui.width, markDirty))
552
+ this.subscriptions.add(store.watch((state) => state.ui.height, markDirty))
553
+
554
+ // Definitions registered during bootstrap predate these listeners, so seed
555
+ // the store with the current snapshots once.
556
+ store.patch({
557
+ processes: this.processes.list(),
558
+ services: this.services.list(),
559
+ logSources: this.logs.sources(),
560
+ })
561
+ store.setState((state) => ({
562
+ ...state,
563
+ ui: { ...state.ui, logSource: state.ui.logSource ?? this.logs.sources()[0]?.id ?? null },
564
+ }))
565
+ }
566
+
567
+ /**
568
+ * Kick off background work after the first frame.
569
+ * Nothing here blocks the UI: the dashboard is usable while it runs.
570
+ */
571
+ private startAppTimers(): void {
572
+ const clockTimer = setInterval(
573
+ () => updateUi(this.store, { clock: new Date() }),
574
+ this.config.refresh.clock,
575
+ )
576
+ // Processes report their own state changes; this timer only refreshes the
577
+ // uptime column.
578
+ const uptimeTimer = setInterval(() => {
579
+ if (this.activeView === "processes" || this.activeView === "overview") this.markViewDirty()
580
+ }, 1_000)
581
+ this.timers = [clockTimer, uptimeTimer]
582
+ for (const timer of this.timers) timer.unref?.()
583
+ }
584
+
585
+ /**
586
+ * Pollers for one project. Only the active project polls git, services and
587
+ * system metrics - background projects merely keep their processes alive.
588
+ */
589
+ private startSessionBackground(session: ProjectSession): void {
590
+ this.stopSessionBackground(session)
591
+ session.system.start()
592
+
593
+ const gitTimer = setInterval(() => void session.git.refresh(), session.config.refresh.git)
594
+ gitTimer.unref?.()
595
+ session.timers.push(gitTimer)
596
+
597
+ void session.git.refresh()
598
+ void session.services.detectAll().then(() => session.services.start())
599
+ void session.system.refreshTools(session.project.packageManager.manager)
600
+ if (!session.processes.list().some((entry) => entry.startedAt)) {
601
+ // Autostart only the first time a project is opened, never on reactivation.
602
+ void session.processes.startAutoStart()
603
+ }
604
+ }
605
+
606
+ private stopSessionBackground(session: ProjectSession): void {
607
+ for (const timer of session.timers) clearInterval(timer)
608
+ session.timers = []
609
+ session.system.stop()
610
+ session.services.stop()
611
+ }
612
+
613
+ // ---------------------------------------------------------------------
614
+ // Rendering
615
+ // ---------------------------------------------------------------------
616
+
617
+ private renderAll(): void {
618
+ const state = this.store.getState()
619
+ this.header.update(state)
620
+ this.footer.setWidth(state.ui.width)
621
+ this.sidebar.setActive(state.ui.view)
622
+ this.updateFooterHints()
623
+ this.views.get(this.activeView)?.update(state)
624
+ }
625
+
626
+ private markViewDirty(): void {
627
+ if (this.viewDirty) return
628
+ this.viewDirty = true
629
+ queueMicrotask(() => {
630
+ this.viewDirty = false
631
+ this.views.get(this.activeView)?.update(this.store.getState())
632
+ })
633
+ }
634
+
635
+ /** Log output can arrive in bursts; coalesce redraws. */
636
+ private scheduleLogUpdate(): void {
637
+ if (this.logUpdateTimer) return
638
+ this.logUpdateTimer = setTimeout(() => {
639
+ this.logUpdateTimer = null
640
+ this.views.get(this.activeView)?.update(this.store.getState())
641
+ }, LOG_UPDATE_THROTTLE_MS)
642
+ this.logUpdateTimer.unref?.()
643
+ }
644
+
645
+ private setView(view: ViewId): void {
646
+ if (this.views.size === 0) return
647
+ const previous = this.views.get(this.activeView)
648
+ if (previous && this.activeView !== view) {
649
+ previous.box.visible = false
650
+ previous.onDeactivate()
651
+ }
652
+
653
+ this.activeView = view
654
+ const next = this.views.get(view)
655
+ if (next) {
656
+ next.box.visible = true
657
+ next.onActivate(this.store.getState())
658
+ next.update(this.store.getState())
659
+ }
660
+
661
+ this.sidebar.setActive(view)
662
+ this.updateFooterHints()
663
+ }
664
+
665
+ private updateFooterHints(): void {
666
+ const view = this.views.get(this.activeView)
667
+ const hints: KeyHint[] = view?.hints() ?? []
668
+ this.footer.setHints([...hints, { keys: "q", label: "quit" }])
669
+ }
670
+
671
+ private overlays(): Overlay[] {
672
+ return [this.palette, this.projectPicker, this.confirmDialog, this.promptDialog, this.helpOverlay]
673
+ }
674
+
675
+ /** Stop every app-level timer and every session's pollers. */
676
+ private stopBackground(): void {
677
+ for (const timer of this.timers) clearInterval(timer)
678
+ this.timers = []
679
+ if (this.logUpdateTimer) {
680
+ clearTimeout(this.logUpdateTimer)
681
+ this.logUpdateTimer = null
682
+ }
683
+ for (const session of this.sessions.values()) this.stopSessionBackground(session)
684
+ }
685
+
686
+ private handleResize(width: number, height: number): void {
687
+ const sidebarVisible = width >= NARROW_WIDTH
688
+ updateUi(this.store, { width, height, sidebarVisible })
689
+ // Applied directly as well as through the watcher: the first resize happens
690
+ // while the UI is still being assembled, before the watchers exist.
691
+ this.sidebar.box.visible = sidebarVisible
692
+ this.footer.setWidth(width)
693
+ for (const overlay of this.overlays()) {
694
+ overlay.layout(width, height)
695
+ }
696
+ this.header.update(this.store.getState())
697
+ }
698
+
699
+ // ---------------------------------------------------------------------
700
+ // Input
701
+ // ---------------------------------------------------------------------
702
+
703
+ private handleKey(key: KeyEvent): void {
704
+ if (this.shuttingDown) return
705
+
706
+ // Ctrl+C always means "quit cleanly".
707
+ if (key.ctrl && key.name === "c") {
708
+ key.preventDefault()
709
+ key.stopPropagation()
710
+ void this.quit()
711
+ return
712
+ }
713
+
714
+ if (this.confirmDialog.pending) {
715
+ this.handleConfirmKey(key)
716
+ return
717
+ }
718
+ if (this.promptDialog.pending) {
719
+ this.handlePromptKey(key)
720
+ return
721
+ }
722
+ if (this.projectPicker.visible) {
723
+ this.handleProjectPickerKey(key)
724
+ return
725
+ }
726
+ if (this.palette.visible) {
727
+ this.handlePaletteKey(key)
728
+ return
729
+ }
730
+ if (this.helpOverlay.visible) {
731
+ key.preventDefault()
732
+ if (key.name === "escape" || key.name === "q" || key.name === "?" || key.name === "f1")
733
+ this.closeOverlays()
734
+ else if (key.name === "j" || key.name === "down") this.helpOverlay.scroll(1)
735
+ else if (key.name === "k" || key.name === "up") this.helpOverlay.scroll(-1)
736
+ return
737
+ }
738
+
739
+ // A few bindings are global no matter which pane is focused. Shifted letters
740
+ // arrive as the lowercase name plus `shift`, so match on that.
741
+ // ctrl+k is the manual; the palette is `/`.
742
+ if (key.ctrl && key.name === "k") {
743
+ key.preventDefault()
744
+ this.openHelp()
745
+ return
746
+ }
747
+ if (key.ctrl && key.name === "p") {
748
+ key.preventDefault()
749
+ this.openProjectPicker()
750
+ return
751
+ }
752
+ if (key.ctrl && key.name === "o") {
753
+ key.preventDefault()
754
+ this.cycleProject(key.shift ? -1 : 1)
755
+ return
756
+ }
757
+ if (key.shift && key.name === "r") {
758
+ key.preventDefault()
759
+ void this.commands.run("app.refresh", this.context)
760
+ return
761
+ }
762
+
763
+ const state = this.store.getState()
764
+
765
+ // Views get first refusal so `s`/`r`/`d` mean the right thing per pane.
766
+ const view = this.views.get(this.activeView)
767
+ if (view?.handleKey(key, state)) {
768
+ key.preventDefault()
769
+ return
770
+ }
771
+
772
+ switch (key.name) {
773
+ case "q":
774
+ key.preventDefault()
775
+ void this.quit()
776
+ return
777
+ case "?":
778
+ case "f1":
779
+ key.preventDefault()
780
+ this.openHelp()
781
+ return
782
+ case "/":
783
+ key.preventDefault()
784
+ this.openPalette()
785
+ return
786
+ case "escape":
787
+ key.preventDefault()
788
+ this.closeOverlays()
789
+ return
790
+ case "tab":
791
+ key.preventDefault()
792
+ this.cycleView(key.shift ? -1 : 1)
793
+ return
794
+ case "left":
795
+ case "h":
796
+ key.preventDefault()
797
+ this.cycleView(-1)
798
+ return
799
+ case "right":
800
+ key.preventDefault()
801
+ this.cycleView(1)
802
+ return
803
+ case "up":
804
+ key.preventDefault()
805
+ this.moveSelection(-1)
806
+ return
807
+ case "down":
808
+ key.preventDefault()
809
+ this.moveSelection(1)
810
+ return
811
+ default:
812
+ break
813
+ }
814
+
815
+ const shortcutView = VIEW_SHORTCUTS[key.name]
816
+ if (shortcutView && !key.ctrl && !key.meta) {
817
+ key.preventDefault()
818
+ updateUi(this.store, { view: shortcutView })
819
+ }
820
+ }
821
+
822
+ private handleConfirmKey(key: KeyEvent): void {
823
+ key.preventDefault()
824
+ key.stopPropagation()
825
+ if (key.name === "y" || key.name === "return") this.confirmDialog.resolve(true)
826
+ else if (key.name === "n" || key.name === "escape" || key.name === "q") this.confirmDialog.resolve(false)
827
+ }
828
+
829
+ private handlePromptKey(key: KeyEvent): void {
830
+ if (key.name === "escape") {
831
+ key.preventDefault()
832
+ key.stopPropagation()
833
+ this.promptDialog.resolve(null)
834
+ return
835
+ }
836
+ if (key.name === "return") {
837
+ key.preventDefault()
838
+ key.stopPropagation()
839
+ this.promptDialog.submit()
840
+ return
841
+ }
842
+ // Everything else belongs to the focused input.
843
+ }
844
+
845
+ private handlePaletteKey(key: KeyEvent): void {
846
+ switch (key.name) {
847
+ case "escape":
848
+ key.preventDefault()
849
+ key.stopPropagation()
850
+ this.closeOverlays()
851
+ return
852
+ case "return": {
853
+ key.preventDefault()
854
+ key.stopPropagation()
855
+ const selected = this.palette.selected
856
+ this.closeOverlays()
857
+ if (selected) void this.runCommand(selected.id)
858
+ return
859
+ }
860
+ case "up":
861
+ key.preventDefault()
862
+ key.stopPropagation()
863
+ this.palette.move(-1)
864
+ return
865
+ case "down":
866
+ key.preventDefault()
867
+ key.stopPropagation()
868
+ this.palette.move(1)
869
+ return
870
+ default:
871
+ if (key.ctrl && (key.name === "n" || key.name === "p")) {
872
+ key.preventDefault()
873
+ key.stopPropagation()
874
+ this.palette.move(key.name === "n" ? 1 : -1)
875
+ return
876
+ }
877
+ // Let the input consume the key, then re-filter.
878
+ setTimeout(() => this.refreshPalette(), 0)
879
+ }
880
+ }
881
+
882
+ private handleProjectPickerKey(key: KeyEvent): void {
883
+ switch (key.name) {
884
+ case "escape":
885
+ key.preventDefault()
886
+ key.stopPropagation()
887
+ this.closeOverlays()
888
+ return
889
+ case "return": {
890
+ key.preventDefault()
891
+ key.stopPropagation()
892
+ const selected = this.projectPicker.selected
893
+ this.closeOverlays()
894
+ if (selected) void this.openProject(selected.path)
895
+ return
896
+ }
897
+ case "up":
898
+ key.preventDefault()
899
+ key.stopPropagation()
900
+ this.projectPicker.move(-1)
901
+ return
902
+ case "down":
903
+ key.preventDefault()
904
+ key.stopPropagation()
905
+ this.projectPicker.move(1)
906
+ return
907
+ default:
908
+ if (key.ctrl && (key.name === "n" || key.name === "p")) {
909
+ key.preventDefault()
910
+ key.stopPropagation()
911
+ this.projectPicker.move(key.name === "n" ? 1 : -1)
912
+ return
913
+ }
914
+ setTimeout(() => this.refreshProjectPicker(), 0)
915
+ }
916
+ }
917
+
918
+ private cycleView(direction: 1 | -1): void {
919
+ const index = VIEW_IDS.indexOf(this.store.getState().ui.view)
920
+ const next = (index + direction + VIEW_IDS.length) % VIEW_IDS.length
921
+ const view = VIEW_IDS[next]
922
+ if (view) updateUi(this.store, { view })
923
+ }
924
+
925
+ private moveSelection(delta: number): void {
926
+ const state = this.store.getState()
927
+ const current = state.ui.selection[state.ui.view]
928
+ setSelection(this.store, state.ui.view, Math.max(0, current + delta))
929
+ }
930
+
931
+ // ---------------------------------------------------------------------
932
+ // Overlays
933
+ // ---------------------------------------------------------------------
934
+
935
+ private openPalette(): void {
936
+ updateUi(this.store, { overlay: "palette" })
937
+ this.palette.open()
938
+ this.refreshPalette()
939
+ }
940
+
941
+ /** Scanning the filesystem is quick but not free, so do it on open. */
942
+ private openProjectPicker(): void {
943
+ const projects = this.listProjects()
944
+ this.store.patch({ projects })
945
+ updateUi(this.store, { overlay: "projects" })
946
+ this.projectPicker.open(this.session.rootDir)
947
+ this.refreshProjectPicker()
948
+ }
949
+
950
+ private refreshProjectPicker(): void {
951
+ if (!this.projectPicker.visible) return
952
+ const projects = this.store.getState().projects
953
+ const query = this.projectPicker.query
954
+ this.projectPicker.setResults(
955
+ query.trim() === ""
956
+ ? projects
957
+ : fuzzyFilter(projects, query, (entry) => `${entry.name} ${entry.path}`).map((m) => m.item),
958
+ )
959
+ }
960
+
961
+ private openHelp(): void {
962
+ updateUi(this.store, { overlay: "help" })
963
+ this.helpOverlay.show()
964
+ }
965
+
966
+ private refreshPalette(): void {
967
+ if (!this.palette.visible) return
968
+ this.palette.setResults(this.commands.search(this.palette.query, this.context))
969
+ }
970
+
971
+ private closeOverlays(): void {
972
+ this.palette.close()
973
+ this.projectPicker.close()
974
+ this.helpOverlay.hide()
975
+ updateUi(this.store, { overlay: "none" })
976
+ }
977
+
978
+ private async runCommand(id: string): Promise<void> {
979
+ try {
980
+ await this.commands.run(id, this.context)
981
+ } catch (error) {
982
+ const devccError = toDevccError(error, "command", `Command "${id}" failed`)
983
+ this.pushNotification("error", devccError.message, devccError.detail)
984
+ }
985
+ }
986
+
987
+ // ---------------------------------------------------------------------
988
+ // Notifications
989
+ // ---------------------------------------------------------------------
990
+
991
+ private pushNotification(level: NotificationLevel, message: string, detail?: string): void {
992
+ const notification: Notification = {
993
+ id: (this.notificationSeq += 1),
994
+ level,
995
+ message,
996
+ detail,
997
+ createdAt: new Date(),
998
+ }
999
+ this.logger.log(level === "error" ? "error" : level === "warn" ? "warn" : "info", "ui", message, detail)
1000
+ this.store.setState((state) => ({
1001
+ ...state,
1002
+ notifications: [notification, ...state.notifications].slice(0, 200),
1003
+ }))
1004
+ }
1005
+
1006
+ private showNotification(notification: Notification | undefined): void {
1007
+ if (!notification) {
1008
+ this.footer.setStatus(null)
1009
+ return
1010
+ }
1011
+ const prefix = notification.level === "error" || notification.level === "warn" ? "⚠ " : ""
1012
+ this.footer.setStatus(
1013
+ `${prefix}${notification.message}`,
1014
+ notification.level === "error" ? "error" : notification.level === "success" ? "success" : "info",
1015
+ )
1016
+ }
1017
+
1018
+ // ---------------------------------------------------------------------
1019
+ // Command context
1020
+ // ---------------------------------------------------------------------
1021
+
1022
+ /**
1023
+ * The context handed to commands and views.
1024
+ *
1025
+ * Built once and kept stable: session services are read through getters, so a
1026
+ * project switch swaps the backends underneath every existing view without
1027
+ * rebuilding the UI.
1028
+ */
1029
+ private createCommandContext(): CommandContext {
1030
+ const context: CommandContext = {
1031
+ store: this.store,
1032
+ processes: this.processes,
1033
+ services: this.services,
1034
+ git: this.git,
1035
+ logs: this.logs,
1036
+ system: this.system,
1037
+ deps: this.deps,
1038
+ navigate: (view) => updateUi(this.store, { view }),
1039
+ notify: (level, message, detail) => this.pushNotification(level, message, detail),
1040
+ confirm: (title, message) => this.askConfirm(title, message),
1041
+ prompt: (title, placeholder) => this.askPrompt(title, placeholder),
1042
+ showHelp: () => this.openHelp(),
1043
+ runScript: (name) => this.runScript(name),
1044
+ updateDependency: (name, version) => this.updateDependency(name, version),
1045
+ listProjects: () => this.listProjects(),
1046
+ openProjects: () => this.openProjects(),
1047
+ switchProject: (path) => this.openProject(path),
1048
+ closeProject: (path) => this.closeProject(path),
1049
+ quit: () => void this.quit(),
1050
+ }
1051
+
1052
+ // Session services are swapped out on a project switch, so expose them as
1053
+ // getters rather than snapshots taken at construction time.
1054
+ Object.defineProperties(context, {
1055
+ processes: { get: () => this.processes, enumerable: true },
1056
+ services: { get: () => this.services, enumerable: true },
1057
+ git: { get: () => this.git, enumerable: true },
1058
+ logs: { get: () => this.logs, enumerable: true },
1059
+ system: { get: () => this.system, enumerable: true },
1060
+ deps: { get: () => this.deps, enumerable: true },
1061
+ })
1062
+
1063
+ return context
1064
+ }
1065
+
1066
+ private async askConfirm(title: string, message: string): Promise<boolean> {
1067
+ updateUi(this.store, { overlay: "confirm" })
1068
+ const answer = await this.confirmDialog.ask(title, message)
1069
+ updateUi(this.store, { overlay: "none" })
1070
+ return answer
1071
+ }
1072
+
1073
+ private async askPrompt(title: string, placeholder?: string): Promise<string | null> {
1074
+ updateUi(this.store, { overlay: "prompt" })
1075
+ const answer = await this.promptDialog.ask(title, placeholder)
1076
+ updateUi(this.store, { overlay: "none" })
1077
+ return answer
1078
+ }
1079
+
1080
+ // ---------------------------------------------------------------------
1081
+ // Scripts and config commands
1082
+ // ---------------------------------------------------------------------
1083
+
1084
+ /** Run a package.json script as a managed one-shot process. */
1085
+ private async runScript(name: string): Promise<void> {
1086
+ const state = this.store.getState()
1087
+ const script = state.scripts.available.find((entry) => entry.name === name)
1088
+ if (!script) {
1089
+ this.pushNotification("warn", `No script named "${name}"`)
1090
+ return
1091
+ }
1092
+
1093
+ const id = scriptProcessId(name)
1094
+ const manager = state.project.packageManager.manager
1095
+
1096
+ if (this.processes.isRunning(id)) {
1097
+ this.pushNotification("warn", `${name} is already running`)
1098
+ return
1099
+ }
1100
+
1101
+ this.processes.register({
1102
+ id,
1103
+ name: `${manager} run ${name}`,
1104
+ command: manager,
1105
+ args: runScriptArgs(manager, name),
1106
+ cwd: state.project.rootDir,
1107
+ kind: "task",
1108
+ })
1109
+
1110
+ const session = this.session
1111
+ session.scriptRuns = {
1112
+ ...session.scriptRuns,
1113
+ [name]: { scriptName: name, processId: id, startedAt: new Date(), status: "running" },
1114
+ }
1115
+ this.store.setState((current) => ({
1116
+ ...current,
1117
+ scripts: { ...current.scripts, runs: session.scriptRuns },
1118
+ ui: { ...current.ui, logSource: id },
1119
+ }))
1120
+
1121
+ try {
1122
+ await this.processes.start(id)
1123
+ this.pushNotification("info", `Running ${manager} run ${name}`)
1124
+ } catch (error) {
1125
+ this.finishScriptRun(session, id, 1)
1126
+ this.pushNotification("error", errorMessage(error))
1127
+ }
1128
+ }
1129
+
1130
+ private finishScriptRun(session: ProjectSession, processId: string, exitCode: number): void {
1131
+ const entry = Object.values(session.scriptRuns).find((run) => run.processId === processId)
1132
+ if (!entry || entry.status !== "running") return
1133
+ session.scriptRuns = {
1134
+ ...session.scriptRuns,
1135
+ [entry.scriptName]: {
1136
+ ...entry,
1137
+ finishedAt: new Date(),
1138
+ exitCode,
1139
+ status: exitCode === 0 ? "succeeded" : "failed",
1140
+ },
1141
+ }
1142
+ if (session === this.session) {
1143
+ this.store.setState((state) => ({ ...state, scripts: { ...state.scripts, runs: session.scriptRuns } }))
1144
+ }
1145
+ }
1146
+
1147
+ private async runConfigCommand(entry: CommandConfigEntry): Promise<void> {
1148
+ const id = `command:${entry.id}`
1149
+ if (this.processes.isRunning(id)) {
1150
+ this.pushNotification("warn", `${entry.title} is already running`)
1151
+ return
1152
+ }
1153
+ this.processes.register({
1154
+ id,
1155
+ name: entry.title,
1156
+ command: entry.command,
1157
+ args: entry.args,
1158
+ cwd: entry.cwd,
1159
+ kind: "task",
1160
+ })
1161
+ updateUi(this.store, { logSource: id })
1162
+ try {
1163
+ await this.processes.start(id)
1164
+ this.pushNotification("info", `Running ${entry.title}`)
1165
+ } catch (error) {
1166
+ this.pushNotification("error", errorMessage(error))
1167
+ }
1168
+ }
1169
+
1170
+ // ---------------------------------------------------------------------
1171
+ // Projects
1172
+ // ---------------------------------------------------------------------
1173
+
1174
+ /** Current project first, then recents, config entries and sibling directories. */
1175
+ private listProjects(): ProjectEntry[] {
1176
+ const current = this.session.rootDir
1177
+ const searchDirs = [
1178
+ ...this.config.projectSearchDirs.map((dir) => resolve(current, dir)),
1179
+ ...defaultSearchDirs(current),
1180
+ ]
1181
+
1182
+ return buildProjectList({
1183
+ currentPath: current,
1184
+ configured: this.config.projects,
1185
+ recents: [...this.openProjects().filter((entry) => entry.path !== current), ...readRecents()],
1186
+ discovered: discoverProjects({ searchDirs }),
1187
+ })
1188
+ }
1189
+
1190
+ /** Open projects in the order they were opened. */
1191
+ private openProjects(): ProjectEntry[] {
1192
+ return [...this.sessions.values()].map((session) => ({
1193
+ path: session.rootDir,
1194
+ name: session.project.name,
1195
+ source: session === this.session ? "current" : "recent",
1196
+ hasGit: session.git.getSnapshot().isRepository,
1197
+ hasPackageJson: session.scripts.length > 0 || existsSync(join(session.rootDir, "package.json")),
1198
+ }))
1199
+ }
1200
+
1201
+ /**
1202
+ * Open a project, or bring an already-open one to the front.
1203
+ *
1204
+ * Opening never stops anything: the previous project keeps running in the
1205
+ * background with its processes and log capture intact. The renderer, views
1206
+ * and store are shared; only the session behind them changes.
1207
+ */
1208
+ private async openProject(path: string): Promise<void> {
1209
+ const target = resolve(path)
1210
+ if (this.switching || this.shuttingDown) return
1211
+ if (target === this.session.rootDir) {
1212
+ this.pushNotification("info", "Already on that project")
1213
+ return
1214
+ }
1215
+
1216
+ const existing = this.sessions.get(target)
1217
+ if (existing) {
1218
+ this.activateSession(existing)
1219
+ this.pushNotification("info", `Switched to ${existing.project.name}`)
1220
+ return
1221
+ }
1222
+
1223
+ if (!existsSync(target)) {
1224
+ this.pushNotification("error", `No such directory: ${target}`)
1225
+ return
1226
+ }
1227
+
1228
+ this.switching = true
1229
+ try {
1230
+ this.footer.setStatus(`Opening ${basename(target)}…`)
1231
+ const session = await this.createSession(target)
1232
+ this.sessions.set(target, session)
1233
+ this.wireSession(session)
1234
+ this.activateSession(session)
1235
+ this.reportConfigIssues()
1236
+ this.pushNotification("success", `Opened ${session.project.name} (${this.sessions.size} open)`)
1237
+ } catch (error) {
1238
+ this.pushNotification("error", `Could not open project: ${errorMessage(error)}`)
1239
+ } finally {
1240
+ this.switching = false
1241
+ }
1242
+ }
1243
+
1244
+ /** Point the views at `session` and mirror its current state into the store. */
1245
+ private activateSession(session: ProjectSession): void {
1246
+ const previous = this.session
1247
+ if (previous !== session) {
1248
+ this.stopSessionBackground(previous)
1249
+ previous.logger.log("info", "app", "deactivated", { next: session.project.name })
1250
+ }
1251
+
1252
+ this.session = session
1253
+ this.registerSessionCommands()
1254
+
1255
+ // Everything the store holds for a project is derivable from the session,
1256
+ // so switching is a snapshot copy rather than a rebuild.
1257
+ this.store.setState((state) => ({
1258
+ ...state,
1259
+ project: session.project,
1260
+ processes: session.processes.list(),
1261
+ services: session.services.list(),
1262
+ scripts: { available: session.scripts, runs: session.scriptRuns },
1263
+ git: session.git.getSnapshot(),
1264
+ dependencies: session.deps.getSnapshot(),
1265
+ system: session.system.getMetrics(),
1266
+ logSources: session.logs.sources(),
1267
+ projects: [],
1268
+ ui: {
1269
+ ...state.ui,
1270
+ selection: createInitialSelection(),
1271
+ logSource: session.logs.sources()[0]?.id ?? null,
1272
+ logFollow: true,
1273
+ overlay: "none",
1274
+ },
1275
+ }))
1276
+
1277
+ this.startSessionBackground(session)
1278
+ this.refreshSidebarProjects()
1279
+ this.renderAll()
1280
+ session.logger.log("info", "app", "activated", { openProjects: this.sessions.size })
1281
+ }
1282
+
1283
+ /**
1284
+ * Close a project, stopping anything devcc started there (confirmed).
1285
+ * The last open project cannot be closed - quit instead.
1286
+ */
1287
+ private async closeProject(path?: string): Promise<void> {
1288
+ const target = resolve(path ?? this.session.rootDir)
1289
+ const session = this.sessions.get(target)
1290
+ if (!session) {
1291
+ this.pushNotification("warn", "That project is not open")
1292
+ return
1293
+ }
1294
+ if (this.sessions.size === 1) {
1295
+ this.pushNotification("warn", "Cannot close the only open project; press q to quit")
1296
+ return
1297
+ }
1298
+
1299
+ const running = session.processes.list().filter((entry) => isActive(entry.status))
1300
+ if (running.length > 0) {
1301
+ const confirmed = await this.askConfirm(
1302
+ `Close ${session.project.name} and stop ${running.length} running process(es)?`,
1303
+ `${running.map((entry) => entry.name).join(", ")} will be stopped.`,
1304
+ )
1305
+ if (!confirmed) return
1306
+ }
1307
+
1308
+ this.footer.setStatus(`Closing ${session.project.name}…`)
1309
+ this.stopSessionBackground(session)
1310
+ await session.processes.stopAll()
1311
+ session.wiring.dispose()
1312
+ this.sessions.delete(target)
1313
+ session.logger.log("info", "app", "closed project")
1314
+
1315
+ if (session === this.session) {
1316
+ const next = [...this.sessions.values()][0]
1317
+ if (next) this.activateSession(next)
1318
+ } else {
1319
+ this.refreshSidebarProjects()
1320
+ }
1321
+ this.pushNotification("info", `Closed ${session.project.name}`)
1322
+ }
1323
+
1324
+ /** `ctrl+o`: bring the next open project to the front. */
1325
+ private cycleProject(direction: 1 | -1): void {
1326
+ const sessions = [...this.sessions.values()]
1327
+ if (sessions.length < 2) {
1328
+ this.pushNotification("info", "Only one project is open; ctrl+p opens another")
1329
+ return
1330
+ }
1331
+ const index = sessions.indexOf(this.session)
1332
+ const next = sessions[(index + direction + sessions.length) % sessions.length]
1333
+ if (next) this.activateSession(next)
1334
+ }
1335
+
1336
+ /** Feed the rail the per-project running counts. */
1337
+ private refreshSidebarProjects(): void {
1338
+ this.sidebar.setProjects(
1339
+ [...this.sessions.values()].map((session) => {
1340
+ const list = session.processes.list()
1341
+ return {
1342
+ path: session.rootDir,
1343
+ name: session.project.name,
1344
+ running: list.filter((entry) => isActive(entry.status)).length,
1345
+ attention: list.some((entry) => entry.status === "failed"),
1346
+ }
1347
+ }),
1348
+ this.session.rootDir,
1349
+ )
1350
+ }
1351
+
1352
+ // ---------------------------------------------------------------------
1353
+ // Dependencies
1354
+ // ---------------------------------------------------------------------
1355
+
1356
+ /** Update a single package, gated by a confirmation (it rewrites the lockfile). */
1357
+ private async updateDependency(name: string, version: string): Promise<void> {
1358
+ const manager = this.session.project.packageManager.manager
1359
+ const args = updateArgs(manager, name)
1360
+
1361
+ const confirmed = await this.askConfirm(
1362
+ `Update ${name} to ${version}?`,
1363
+ `Runs \`${manager} ${args.join(" ")}\`, which rewrites package.json and the lockfile.`,
1364
+ )
1365
+ if (!confirmed) return
1366
+
1367
+ const id = `deps:${name}`
1368
+ if (this.processes.isRunning(id)) {
1369
+ this.pushNotification("warn", `${name} is already being updated`)
1370
+ return
1371
+ }
1372
+
1373
+ this.processes.register({
1374
+ id,
1375
+ name: `${manager} update ${name}`,
1376
+ command: manager,
1377
+ args,
1378
+ cwd: this.session.rootDir,
1379
+ kind: "task",
1380
+ })
1381
+ updateUi(this.store, { logSource: id })
1382
+
1383
+ try {
1384
+ await this.processes.start(id)
1385
+ this.pushNotification("info", `Updating ${name}…`)
1386
+ } catch (error) {
1387
+ this.pushNotification("error", errorMessage(error))
1388
+ }
1389
+ }
1390
+
1391
+ // ---------------------------------------------------------------------
1392
+ // Shutdown
1393
+ // ---------------------------------------------------------------------
1394
+
1395
+ /**
1396
+ * Graceful shutdown: stop timers and listeners, stop the processes devcc
1397
+ * launched (never anything merely detected), then hand the terminal back.
1398
+ */
1399
+ async quit(): Promise<void> {
1400
+ if (this.shuttingDown) return
1401
+ this.shuttingDown = true
1402
+ this.logger.log("info", "app", "shutting down")
1403
+
1404
+ this.footer.setStatus("Shutting down…")
1405
+
1406
+ this.stopBackground()
1407
+
1408
+ // Every open project, not just the visible one.
1409
+ await Promise.all(
1410
+ [...this.sessions.values()].map(async (session) => {
1411
+ try {
1412
+ await session.processes.stopAll()
1413
+ } catch (error) {
1414
+ session.logger.log("error", "app", "failed to stop processes", errorMessage(error))
1415
+ }
1416
+ session.wiring.dispose()
1417
+ }),
1418
+ )
1419
+ this.subscriptions.dispose()
1420
+
1421
+ for (const view of this.views.values()) view.dispose()
1422
+ this.views.clear()
1423
+
1424
+ try {
1425
+ this.renderer.destroy()
1426
+ } catch (error) {
1427
+ this.logger.log("error", "app", "renderer teardown failed", errorMessage(error))
1428
+ }
1429
+
1430
+ this.exitResolver?.()
1431
+ this.exitResolver = null
1432
+ }
1433
+
1434
+ /** Test hook: the store the UI is bound to. */
1435
+ getStore(): Store<AppState> {
1436
+ return this.store
1437
+ }
1438
+
1439
+ /** Test hook: open/activate a project without driving the picker overlay. */
1440
+ async switchProjectForTest(path: string): Promise<void> {
1441
+ await this.openProject(path)
1442
+ }
1443
+
1444
+ /** Test hook: close a project without the confirmation dialog when nothing runs. */
1445
+ async closeProjectForTest(path: string): Promise<void> {
1446
+ await this.closeProject(path)
1447
+ }
1448
+
1449
+ /** Test hook: dependency-ordered start of the active project's processes. */
1450
+ async startAllForTest(ids?: string[]): Promise<void> {
1451
+ await this.processes.startAll(ids)
1452
+ }
1453
+
1454
+ /** Test hook: root directories of every open project. */
1455
+ openProjectPaths(): string[] {
1456
+ return [...this.sessions.keys()]
1457
+ }
1458
+
1459
+ /** Test hook: the log store backing the Logs view. */
1460
+ getLogs(): LogStore {
1461
+ return this.logs
1462
+ }
1463
+ }