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