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