@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,91 @@
1
+ import { BoxRenderable, type RenderContext } from "@opentui/core"
2
+ import { theme } from "../app/theme.js"
3
+
4
+ export interface OverlayOptions {
5
+ title: string
6
+ /** Fraction of the terminal width, clamped by min/max. */
7
+ widthRatio?: number
8
+ minWidth?: number
9
+ maxWidth?: number
10
+ heightRatio?: number
11
+ minHeight?: number
12
+ maxHeight?: number
13
+ }
14
+
15
+ /**
16
+ * Base class for modal panels.
17
+ *
18
+ * Overlays are absolutely positioned children of the root, so they float above
19
+ * the layout without disturbing it, and they re-centre themselves on resize.
20
+ */
21
+ export class Overlay {
22
+ readonly box: BoxRenderable
23
+ protected terminalWidth = 80
24
+ protected terminalHeight = 24
25
+
26
+ constructor(
27
+ protected readonly ctx: RenderContext,
28
+ protected readonly options: OverlayOptions,
29
+ ) {
30
+ this.box = new BoxRenderable(ctx, {
31
+ position: "absolute",
32
+ zIndex: 100,
33
+ visible: false,
34
+ flexDirection: "column",
35
+ border: true,
36
+ borderStyle: "rounded",
37
+ borderColor: theme.borderFocus,
38
+ backgroundColor: theme.panel,
39
+ title: options.title,
40
+ titleColor: theme.accent,
41
+ paddingLeft: 1,
42
+ paddingRight: 1,
43
+ })
44
+ }
45
+
46
+ get visible(): boolean {
47
+ return this.box.visible
48
+ }
49
+
50
+ show(): void {
51
+ this.box.visible = true
52
+ this.layout(this.terminalWidth, this.terminalHeight)
53
+ }
54
+
55
+ hide(): void {
56
+ this.box.visible = false
57
+ }
58
+
59
+ /** Re-centre for the given terminal size. Safe to call on every resize. */
60
+ layout(terminalWidth: number, terminalHeight: number): void {
61
+ this.terminalWidth = terminalWidth
62
+ this.terminalHeight = terminalHeight
63
+
64
+ const {
65
+ widthRatio = 0.6,
66
+ minWidth = 30,
67
+ maxWidth = 100,
68
+ heightRatio = 0.5,
69
+ minHeight = 5,
70
+ maxHeight = 30,
71
+ } = this.options
72
+
73
+ const width = clampSize(Math.round(terminalWidth * widthRatio), minWidth, maxWidth, terminalWidth - 2)
74
+ const height = clampSize(
75
+ Math.round(terminalHeight * heightRatio),
76
+ minHeight,
77
+ maxHeight,
78
+ terminalHeight - 2,
79
+ )
80
+
81
+ this.box.width = width
82
+ this.box.height = height
83
+ this.box.left = Math.max(0, Math.floor((terminalWidth - width) / 2))
84
+ this.box.top = Math.max(0, Math.floor((terminalHeight - height) / 2))
85
+ }
86
+ }
87
+
88
+ function clampSize(preferred: number, min: number, max: number, available: number): number {
89
+ const upper = Math.max(1, Math.min(max, available))
90
+ return Math.max(1, Math.min(upper, Math.max(min, preferred)))
91
+ }
@@ -0,0 +1,138 @@
1
+ import { BoxRenderable, InputRenderable, TextRenderable, type RenderContext } from "@opentui/core"
2
+ import { theme } from "../app/theme.js"
3
+ import type { ProjectEntry } from "../projects/ProjectRegistry.js"
4
+ import { fit } from "../utils/format.js"
5
+ import { tildify, truncatePath } from "../utils/paths.js"
6
+ import { Overlay } from "./Overlay.js"
7
+ import { RowList, type ListRow } from "./primitives.js"
8
+ import { chunk, dim, styled } from "./styled.js"
9
+
10
+ /**
11
+ * Project switcher (`ctrl+p`).
12
+ *
13
+ * Reattaches the whole dashboard to another checkout: the current project,
14
+ * recently opened ones, anything named in config, and sibling directories.
15
+ */
16
+ export class ProjectPicker extends Overlay {
17
+ readonly input: InputRenderable
18
+ private readonly list: RowList
19
+ private readonly footer: TextRenderable
20
+ private results: ProjectEntry[] = []
21
+ private selectedIndex = 0
22
+ private currentPath = ""
23
+
24
+ constructor(ctx: RenderContext) {
25
+ super(ctx, {
26
+ title: " Switch project ",
27
+ widthRatio: 0.7,
28
+ minWidth: 44,
29
+ maxWidth: 100,
30
+ heightRatio: 0.6,
31
+ minHeight: 9,
32
+ maxHeight: 24,
33
+ })
34
+
35
+ const inputWrapper = new BoxRenderable(ctx, {
36
+ height: 1,
37
+ flexShrink: 0,
38
+ flexDirection: "row",
39
+ marginTop: 1,
40
+ backgroundColor: theme.panelAlt,
41
+ paddingLeft: 1,
42
+ paddingRight: 1,
43
+ })
44
+ inputWrapper.add(
45
+ new TextRenderable(ctx, { content: "› ", fg: theme.accent, width: 2, selectable: false }),
46
+ )
47
+ this.input = new InputRenderable(ctx, {
48
+ flexGrow: 1,
49
+ backgroundColor: theme.panelAlt,
50
+ textColor: theme.textBright,
51
+ placeholder: "Filter projects…",
52
+ placeholderColor: theme.textDim,
53
+ })
54
+ inputWrapper.add(this.input)
55
+
56
+ // Column widths depend on the overlay width, which is only known after
57
+ // layout, so recompute the rows when the viewport changes.
58
+ this.list = new RowList(ctx, { marginTop: 1, flexGrow: 1, onViewportChange: () => this.paint() })
59
+ this.footer = new TextRenderable(ctx, {
60
+ content: "",
61
+ fg: theme.textDim,
62
+ height: 1,
63
+ flexShrink: 0,
64
+ selectable: false,
65
+ truncate: true,
66
+ })
67
+
68
+ this.box.add(inputWrapper)
69
+ this.box.add(this.list.box)
70
+ this.box.add(this.footer)
71
+ }
72
+
73
+ open(currentPath: string): void {
74
+ this.currentPath = currentPath
75
+ this.input.value = ""
76
+ this.selectedIndex = 0
77
+ this.show()
78
+ this.input.focus()
79
+ }
80
+
81
+ close(): void {
82
+ this.input.blur()
83
+ this.hide()
84
+ }
85
+
86
+ get query(): string {
87
+ return this.input.value
88
+ }
89
+
90
+ setResults(results: ProjectEntry[]): void {
91
+ this.results = results
92
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, results.length - 1))
93
+ this.paint()
94
+ }
95
+
96
+ move(delta: number): void {
97
+ if (this.results.length === 0) return
98
+ const next = this.selectedIndex + delta
99
+ this.selectedIndex = ((next % this.results.length) + this.results.length) % this.results.length
100
+ this.paint()
101
+ }
102
+
103
+ get selected(): ProjectEntry | undefined {
104
+ return this.results[this.selectedIndex]
105
+ }
106
+
107
+ private paint(): void {
108
+ const width = Math.max(24, this.box.width - 4)
109
+ const nameWidth = Math.min(28, Math.max(12, Math.floor(width * 0.3)))
110
+
111
+ const rows: ListRow[] = this.results.map((entry) => {
112
+ const isCurrent = entry.path === this.currentPath
113
+ const markers = [entry.hasGit ? "git" : null, entry.hasPackageJson ? "pkg" : null]
114
+ .filter(Boolean)
115
+ .join(" ")
116
+ const pathWidth = Math.max(12, width - nameWidth - 12)
117
+ return {
118
+ key: entry.path,
119
+ content: styled(
120
+ chunk(isCurrent ? "● " : " ", theme.accent),
121
+ chunk(fit(entry.name, nameWidth), isCurrent ? theme.accent : theme.textBright),
122
+ dim(fit(truncatePath(tildify(entry.path), pathWidth - 1), pathWidth)),
123
+ dim(fit(markers, 8)),
124
+ ),
125
+ }
126
+ })
127
+
128
+ if (rows.length === 0) {
129
+ rows.push({ key: "__empty", disabled: true, content: styled(dim("No matching projects")) })
130
+ }
131
+
132
+ this.list.setRows(rows, this.selectedIndex)
133
+ this.footer.content = styled(
134
+ dim(`${this.results.length} project${this.results.length === 1 ? "" : "s"}`),
135
+ dim(" ↑↓ move enter switch esc cancel"),
136
+ )
137
+ }
138
+ }
@@ -0,0 +1,163 @@
1
+ import { BoxRenderable, TextRenderable, type RenderContext } from "@opentui/core"
2
+ import { shortcutHintFor } from "../app/keymap.js"
3
+ import { theme } from "../app/theme.js"
4
+ import type { ViewId } from "../core/state.js"
5
+ import { fit } from "../utils/format.js"
6
+ import { chunk, dim, styled } from "./styled.js"
7
+
8
+ export interface SidebarItem {
9
+ id: ViewId
10
+ label: string
11
+ }
12
+
13
+ export const SIDEBAR_ITEMS: readonly SidebarItem[] = [
14
+ { id: "overview", label: "Overview" },
15
+ { id: "processes", label: "Processes" },
16
+ { id: "services", label: "Services" },
17
+ { id: "scripts", label: "Scripts" },
18
+ { id: "git", label: "Git" },
19
+ { id: "logs", label: "Logs" },
20
+ { id: "dependencies", label: "Dependencies" },
21
+ { id: "system", label: "System" },
22
+ { id: "settings", label: "Settings" },
23
+ ]
24
+
25
+ /** What the rail needs to know about an open project. */
26
+ export interface SidebarProject {
27
+ path: string
28
+ name: string
29
+ /** Processes currently active in that project. */
30
+ running: number
31
+ /** True when a process there has failed or exhausted its restarts. */
32
+ attention: boolean
33
+ }
34
+
35
+ export const SIDEBAR_WIDTH = 23
36
+
37
+ /** Widest label ("Dependencies") plus a space before the shortcut column. */
38
+ const LABEL_WIDTH = 13
39
+ /** Room for the project name; a space and a 3-column badge follow it. */
40
+ const PROJECT_WIDTH = 14
41
+ /** Keep the rail readable on short terminals. */
42
+ const MAX_PROJECT_ROWS = 6
43
+
44
+ /**
45
+ * Left navigation rail.
46
+ *
47
+ * With one project open it is the plain section list. Once a second project is
48
+ * opened a PROJECTS block appears above it - progressive disclosure, so the
49
+ * single-project case looks exactly as it always did.
50
+ */
51
+ export class Sidebar {
52
+ readonly box: BoxRenderable
53
+ private readonly projectsBlock: BoxRenderable
54
+ private readonly projectRows: TextRenderable[] = []
55
+ private readonly sectionHeading: TextRenderable
56
+ private readonly rows = new Map<ViewId, TextRenderable>()
57
+ private activeView: ViewId = "overview"
58
+
59
+ constructor(private readonly ctx: RenderContext) {
60
+ this.box = new BoxRenderable(ctx, {
61
+ width: SIDEBAR_WIDTH,
62
+ flexShrink: 0,
63
+ flexDirection: "column",
64
+ paddingLeft: 1,
65
+ paddingRight: 1,
66
+ paddingTop: 1,
67
+ border: ["right"],
68
+ borderColor: theme.border,
69
+ backgroundColor: theme.panel,
70
+ })
71
+
72
+ this.projectsBlock = new BoxRenderable(ctx, {
73
+ flexDirection: "column",
74
+ flexShrink: 0,
75
+ visible: false,
76
+ backgroundColor: "transparent",
77
+ })
78
+ this.projectsBlock.add(
79
+ new TextRenderable(ctx, { content: "PROJECTS", fg: theme.textDim, height: 1, selectable: false }),
80
+ )
81
+ this.box.add(this.projectsBlock)
82
+
83
+ this.sectionHeading = new TextRenderable(ctx, {
84
+ content: "PROJECT",
85
+ fg: theme.textDim,
86
+ height: 1,
87
+ selectable: false,
88
+ })
89
+ this.box.add(this.sectionHeading)
90
+ this.box.add(new BoxRenderable(ctx, { height: 1, backgroundColor: "transparent" }))
91
+
92
+ for (const item of SIDEBAR_ITEMS) {
93
+ const row = new TextRenderable(ctx, { content: "", height: 1, selectable: false, truncate: true })
94
+ this.rows.set(item.id, row)
95
+ this.box.add(row)
96
+ }
97
+
98
+ this.setActive("overview")
99
+ }
100
+
101
+ setActive(view: ViewId): void {
102
+ this.activeView = view
103
+ for (const item of SIDEBAR_ITEMS) {
104
+ const row = this.rows.get(item.id)
105
+ if (!row) continue
106
+ const active = item.id === view
107
+ // Shortcut hints are derived from the keymap, never hand-written here.
108
+ const hint = shortcutHintFor(item.id)
109
+ row.content = styled(
110
+ chunk(active ? "● " : "○ ", active ? theme.accent : theme.textDim),
111
+ chunk(fit(item.label, LABEL_WIDTH), active ? theme.textBright : theme.text),
112
+ dim(hint),
113
+ )
114
+ row.bg = active ? theme.selectionBackground : "transparent"
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Show the open projects. The block hides itself with a single project, and
120
+ * the section heading switches from "PROJECT" to "SECTIONS" when it shows.
121
+ */
122
+ setProjects(projects: readonly SidebarProject[], activePath: string): void {
123
+ const multi = projects.length > 1
124
+ this.projectsBlock.visible = multi
125
+ this.sectionHeading.content = multi ? "SECTIONS" : "PROJECT"
126
+ if (!multi) return
127
+
128
+ const visible = projects.slice(0, MAX_PROJECT_ROWS)
129
+ while (this.projectRows.length < visible.length + 1) {
130
+ const row = new TextRenderable(this.ctx, { content: "", height: 1, selectable: false, truncate: true })
131
+ this.projectRows.push(row)
132
+ this.projectsBlock.add(row)
133
+ }
134
+
135
+ for (const [index, row] of this.projectRows.entries()) {
136
+ const project = visible[index]
137
+ if (!project) {
138
+ // The last pooled row doubles as the spacer under the block.
139
+ row.visible = index === visible.length
140
+ row.content =
141
+ index === visible.length && projects.length > visible.length
142
+ ? styled(dim(` +${projects.length - visible.length} more`))
143
+ : ""
144
+ row.bg = "transparent"
145
+ continue
146
+ }
147
+ row.visible = true
148
+ const active = project.path === activePath
149
+ const badge = project.running > 0 ? `${project.running}▸` : ""
150
+ row.content = styled(
151
+ chunk(active ? "● " : "○ ", active ? theme.accent : theme.textDim),
152
+ chunk(fit(project.name, PROJECT_WIDTH), active ? theme.textBright : theme.text),
153
+ " ",
154
+ project.attention ? chunk(fit("!", 3), theme.danger) : chunk(fit(badge, 3), theme.success),
155
+ )
156
+ row.bg = active ? theme.selectionBackground : "transparent"
157
+ }
158
+ }
159
+
160
+ get active(): ViewId {
161
+ return this.activeView
162
+ }
163
+ }
@@ -0,0 +1,36 @@
1
+ import { fg, t, type StyledText } from "@opentui/core"
2
+ import { processStatusStyle, serviceStateStyle, theme } from "../app/theme.js"
3
+ import type { ProcessStatus } from "../processes/ManagedProcess.js"
4
+ import type { ServiceState } from "../services/types.js"
5
+ import { fit } from "../utils/format.js"
6
+
7
+ /**
8
+ * Status indicators.
9
+ *
10
+ * A single glyph plus a colour is enough to scan a column at a glance; the
11
+ * word is included at a fixed width so the columns after it stay aligned.
12
+ */
13
+ export function processBadge(status: ProcessStatus, width = 9): StyledText {
14
+ const style = processStatusStyle(status)
15
+ return t`${fg(style.color)(style.glyph)} ${fg(style.color)(fit(style.label, width))}`
16
+ }
17
+
18
+ export function serviceBadge(state: ServiceState, width = 12): StyledText {
19
+ const style = serviceStateStyle(state)
20
+ return t`${fg(style.color)(style.glyph)} ${fg(style.color)(fit(style.label, width))}`
21
+ }
22
+
23
+ export function processGlyph(status: ProcessStatus): StyledText {
24
+ const style = processStatusStyle(status)
25
+ return t`${fg(style.color)(style.glyph)}`
26
+ }
27
+
28
+ export function serviceGlyph(state: ServiceState): StyledText {
29
+ const style = serviceStateStyle(state)
30
+ return t`${fg(style.color)(style.glyph)}`
31
+ }
32
+
33
+ /** `label value` pair with a dim label, aligned to `labelWidth`. */
34
+ export function keyValue(label: string, value: string, labelWidth = 16): StyledText {
35
+ return t`${fg(theme.textDim)(fit(label, labelWidth))}${fg(theme.text)(value)}`
36
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type BoxOptions,
5
+ type RenderContext,
6
+ type StyledText,
7
+ } from "@opentui/core"
8
+ import { theme } from "../app/theme.js"
9
+
10
+ /** Shorthand for a plain text row. */
11
+ export function createText(
12
+ ctx: RenderContext,
13
+ content: StyledText | string,
14
+ options: { fg?: string; bg?: string; height?: number } = {},
15
+ ): TextRenderable {
16
+ return new TextRenderable(ctx, {
17
+ content,
18
+ fg: options.fg ?? theme.text,
19
+ bg: options.bg ?? "transparent",
20
+ height: options.height ?? 1,
21
+ truncate: true,
22
+ selectable: false,
23
+ })
24
+ }
25
+
26
+ /** A section heading inside a panel. */
27
+ export function createHeading(ctx: RenderContext, label: string): TextRenderable {
28
+ return new TextRenderable(ctx, {
29
+ content: label.toUpperCase(),
30
+ fg: theme.accent,
31
+ height: 1,
32
+ selectable: false,
33
+ truncate: true,
34
+ })
35
+ }
36
+
37
+ export function createBox(ctx: RenderContext, options: BoxOptions = {}): BoxRenderable {
38
+ return new BoxRenderable(ctx, {
39
+ backgroundColor: "transparent",
40
+ ...options,
41
+ })
42
+ }
43
+
44
+ /** A vertical spacer. */
45
+ export function createSpacer(ctx: RenderContext, height = 1): BoxRenderable {
46
+ return new BoxRenderable(ctx, { height, backgroundColor: "transparent" })
47
+ }
48
+
49
+ export interface ListRow {
50
+ /** Stable identity, used to preserve selection across refreshes. */
51
+ key: string
52
+ content: StyledText | string
53
+ /** Rendered as a non-selectable row (group headings, empty states). */
54
+ disabled?: boolean
55
+ }
56
+
57
+ export interface RowListOptions extends BoxOptions {
58
+ /** Rows kept visible above/below the selection while scrolling. */
59
+ scrollPadding?: number
60
+ /**
61
+ * Called when the viewport height changes. Views that window their own data
62
+ * (the log viewer) use this to recompute the slice they hand over.
63
+ */
64
+ onViewportChange?: () => void
65
+ }
66
+
67
+ /**
68
+ * A scrolling, selectable list of single-line rows.
69
+ *
70
+ * Renderables are recycled: refreshing the data updates existing
71
+ * `TextRenderable` contents instead of rebuilding the subtree, and only the rows
72
+ * that fit the current viewport are materialised.
73
+ */
74
+ export class RowList {
75
+ readonly box: BoxRenderable
76
+ private readonly rowRenderables: TextRenderable[] = []
77
+ private rows: ListRow[] = []
78
+ private selectedIndex = 0
79
+ private scrollOffset = 0
80
+ private focused = true
81
+ private readonly scrollPadding: number
82
+ private readonly onViewportChange: (() => void) | undefined
83
+ private lastVisibleCount = 0
84
+
85
+ constructor(
86
+ private readonly ctx: RenderContext,
87
+ options: RowListOptions = {},
88
+ ) {
89
+ const { scrollPadding, onViewportChange, ...boxOptions } = options
90
+ this.scrollPadding = scrollPadding ?? 1
91
+ this.onViewportChange = onViewportChange
92
+ this.box = new BoxRenderable(ctx, {
93
+ flexDirection: "column",
94
+ flexGrow: 1,
95
+ overflow: "hidden",
96
+ backgroundColor: "transparent",
97
+ ...boxOptions,
98
+ })
99
+ this.box.onSizeChange = () => {
100
+ const visible = this.visibleCount
101
+ this.paint()
102
+ if (visible !== this.lastVisibleCount) {
103
+ this.lastVisibleCount = visible
104
+ this.onViewportChange?.()
105
+ }
106
+ }
107
+ }
108
+
109
+ get length(): number {
110
+ return this.rows.length
111
+ }
112
+
113
+ get visibleCount(): number {
114
+ return Math.max(1, this.box.height)
115
+ }
116
+
117
+ get offset(): number {
118
+ return this.scrollOffset
119
+ }
120
+
121
+ setFocused(focused: boolean): void {
122
+ if (this.focused === focused) return
123
+ this.focused = focused
124
+ this.paint()
125
+ }
126
+
127
+ setRows(rows: ListRow[], selectedIndex: number): void {
128
+ this.rows = rows
129
+ this.selectedIndex = clamp(selectedIndex, 0, Math.max(0, rows.length - 1))
130
+ this.ensureVisible()
131
+ this.paint()
132
+ }
133
+
134
+ setSelectedIndex(index: number): void {
135
+ const next = clamp(index, 0, Math.max(0, this.rows.length - 1))
136
+ if (next === this.selectedIndex) return
137
+ this.selectedIndex = next
138
+ this.ensureVisible()
139
+ this.paint()
140
+ }
141
+
142
+ scrollBy(delta: number): void {
143
+ const maxOffset = Math.max(0, this.rows.length - this.visibleCount)
144
+ this.scrollOffset = clamp(this.scrollOffset + delta, 0, maxOffset)
145
+ this.paint()
146
+ }
147
+
148
+ scrollToEnd(): void {
149
+ this.scrollOffset = Math.max(0, this.rows.length - this.visibleCount)
150
+ this.paint()
151
+ }
152
+
153
+ private ensureVisible(): void {
154
+ const visible = this.visibleCount
155
+ const padding = Math.min(this.scrollPadding, Math.floor((visible - 1) / 2))
156
+ const maxOffset = Math.max(0, this.rows.length - visible)
157
+
158
+ if (this.selectedIndex < this.scrollOffset + padding) {
159
+ this.scrollOffset = this.selectedIndex - padding
160
+ } else if (this.selectedIndex > this.scrollOffset + visible - 1 - padding) {
161
+ this.scrollOffset = this.selectedIndex - visible + 1 + padding
162
+ }
163
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxOffset)
164
+ }
165
+
166
+ private paint(): void {
167
+ const visible = this.visibleCount
168
+ this.ensurePool(Math.min(visible, Math.max(this.rows.length, 1)))
169
+
170
+ for (const [index, renderable] of this.rowRenderables.entries()) {
171
+ const rowIndex = this.scrollOffset + index
172
+ const row = this.rows[rowIndex]
173
+ if (!row) {
174
+ renderable.visible = false
175
+ continue
176
+ }
177
+ renderable.visible = true
178
+ renderable.content = row.content
179
+ const selected = rowIndex === this.selectedIndex && !row.disabled
180
+ renderable.bg = selected ? (this.focused ? theme.selectionBackground : theme.panelAlt) : "transparent"
181
+ }
182
+ }
183
+
184
+ private ensurePool(size: number): void {
185
+ while (this.rowRenderables.length < size) {
186
+ const renderable = new TextRenderable(this.ctx, {
187
+ content: "",
188
+ fg: theme.text,
189
+ height: 1,
190
+ flexShrink: 0,
191
+ truncate: true,
192
+ selectable: false,
193
+ })
194
+ this.rowRenderables.push(renderable)
195
+ this.box.add(renderable)
196
+ }
197
+ while (this.rowRenderables.length > size) {
198
+ const renderable = this.rowRenderables.pop()
199
+ if (!renderable) break
200
+ this.box.remove(renderable)
201
+ renderable.destroy()
202
+ }
203
+ }
204
+
205
+ destroy(): void {
206
+ this.box.destroyRecursively()
207
+ }
208
+ }
209
+
210
+ export function clamp(value: number, min: number, max: number): number {
211
+ if (max < min) return min
212
+ return Math.max(min, Math.min(max, value))
213
+ }
@@ -0,0 +1,44 @@
1
+ import { bold as boldChunk, dim as dimChunk, fg, StyledText, type TextChunk } from "@opentui/core"
2
+ import { theme } from "../app/theme.js"
3
+
4
+ /**
5
+ * Helpers for composing `StyledText` programmatically.
6
+ *
7
+ * The `t` template literal is convenient for fixed strings; these helpers cover
8
+ * the dynamic case (variable numbers of coloured segments) without fighting the
9
+ * template syntax.
10
+ */
11
+
12
+ export function chunk(text: string, color: string = theme.text): TextChunk {
13
+ return fg(color)(text)
14
+ }
15
+
16
+ export function bold(text: string, color: string = theme.textBright): TextChunk {
17
+ return boldChunk(fg(color)(text))
18
+ }
19
+
20
+ export function dim(text: string): TextChunk {
21
+ return dimChunk(fg(theme.textDim)(text))
22
+ }
23
+
24
+ export function styled(...parts: (TextChunk | string)[]): StyledText {
25
+ return new StyledText(parts.map((part) => (typeof part === "string" ? chunk(part) : part)))
26
+ }
27
+
28
+ export function joinStyled(
29
+ parts: StyledText[],
30
+ separator: string,
31
+ separatorColor = theme.textDim,
32
+ ): StyledText {
33
+ const chunks: TextChunk[] = []
34
+ for (const [index, part] of parts.entries()) {
35
+ if (index > 0) chunks.push(chunk(separator, separatorColor))
36
+ chunks.push(...part.chunks)
37
+ }
38
+ return new StyledText(chunks)
39
+ }
40
+
41
+ /** Total display length of a styled line, for right-alignment maths. */
42
+ export function styledLength(text: StyledText): number {
43
+ return text.chunks.reduce((total, part) => total + part.text.length, 0)
44
+ }