tmux-ide 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, io-free helpers for the built-in git diff panel (M18.3).
|
|
3
|
+
*
|
|
4
|
+
* Like {@link ./editor-buffer.ts}, this imports NOTHING from @opentui/core: the
|
|
5
|
+
* app runs the git subprocesses (async execFile) and reads untracked files, but
|
|
6
|
+
* the PARSING and coordinate math live here so they unit-test as tables under
|
|
7
|
+
* the Node/vitest runner while app.tsx keeps the OpenTUI wiring.
|
|
8
|
+
*
|
|
9
|
+
* Three concerns: (1) parse `git status --porcelain` into a flat, display-ready
|
|
10
|
+
* file list; (2) classify each `git diff` line so the renderer can color it; and
|
|
11
|
+
* (3) turn an untracked file's contents into an all-additions pseudo-diff (git
|
|
12
|
+
* diff prints nothing for untracked paths).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One changed file from `git status --porcelain`, display-ready. */
|
|
16
|
+
export interface StatusEntry {
|
|
17
|
+
/** Single status letter for the list: M(odified) · A(dded) · D(eleted) ·
|
|
18
|
+
* R(enamed) · ?(untracked) · etc. Worktree state wins over index state so a
|
|
19
|
+
* file staged-then-edited still reads as M. */
|
|
20
|
+
status: string;
|
|
21
|
+
/** Repo-relative path (the NEW path for renames). */
|
|
22
|
+
path: string;
|
|
23
|
+
/** True when the index carries a change (a staged component exists). */
|
|
24
|
+
staged: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* PURE — parse porcelain v1 output. Each line is `XY<space>PATH`, where X is the
|
|
29
|
+
* index state and Y the worktree state; renames/copies render as `orig -> new`.
|
|
30
|
+
* Untracked files come through as `??`. Malformed/short lines are skipped.
|
|
31
|
+
*/
|
|
32
|
+
export function parseStatusPorcelain(out: string): StatusEntry[] {
|
|
33
|
+
const entries: StatusEntry[] = [];
|
|
34
|
+
for (const line of out.split("\n")) {
|
|
35
|
+
if (line.length < 4) continue;
|
|
36
|
+
const index = line[0]!;
|
|
37
|
+
const work = line[1]!;
|
|
38
|
+
let rest = line.slice(3);
|
|
39
|
+
const arrow = rest.indexOf(" -> ");
|
|
40
|
+
if (arrow !== -1) rest = rest.slice(arrow + 4);
|
|
41
|
+
const untracked = index === "?" && work === "?";
|
|
42
|
+
const status = untracked ? "?" : work !== " " ? work : index;
|
|
43
|
+
entries.push({ status, path: rest, staged: !untracked && index !== " " });
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a unified-diff line renders: added / removed / hunk header / file-meta
|
|
49
|
+
* (diff/index/+++/---/mode/rename) / plain context. */
|
|
50
|
+
export type DiffLineKind = "add" | "del" | "hunk" | "meta" | "context";
|
|
51
|
+
|
|
52
|
+
/** One classified diff line: its kind and its raw text (leading sigil kept). */
|
|
53
|
+
export interface DiffLine {
|
|
54
|
+
kind: DiffLineKind;
|
|
55
|
+
text: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const META_PREFIXES = [
|
|
59
|
+
"diff ",
|
|
60
|
+
"index ",
|
|
61
|
+
"new file",
|
|
62
|
+
"deleted file",
|
|
63
|
+
"old mode",
|
|
64
|
+
"new mode",
|
|
65
|
+
"similarity ",
|
|
66
|
+
"dissimilarity ",
|
|
67
|
+
"rename ",
|
|
68
|
+
"copy ",
|
|
69
|
+
"Binary ",
|
|
70
|
+
"\\ No newline",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* PURE — classify one raw diff line. Order matters: the `+++`/`---` file headers
|
|
75
|
+
* must be caught as meta BEFORE the bare `+`/`-` add/del checks.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyDiffLine(line: string): DiffLineKind {
|
|
78
|
+
if (line.startsWith("@@")) return "hunk";
|
|
79
|
+
if (line.startsWith("+++") || line.startsWith("---")) return "meta";
|
|
80
|
+
for (const p of META_PREFIXES) if (line.startsWith(p)) return "meta";
|
|
81
|
+
if (line.startsWith("+")) return "add";
|
|
82
|
+
if (line.startsWith("-")) return "del";
|
|
83
|
+
return "context";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* PURE — split a `git diff --no-color` blob into classified lines, dropping the
|
|
88
|
+
* single trailing empty produced by the final newline.
|
|
89
|
+
*/
|
|
90
|
+
export function classifyDiff(diff: string): DiffLine[] {
|
|
91
|
+
if (!diff) return [];
|
|
92
|
+
const lines = diff.split("\n");
|
|
93
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
94
|
+
return lines.map((text) => ({ kind: classifyDiffLine(text), text }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* PURE — render an untracked file's contents as an all-additions pseudo-diff
|
|
99
|
+
* (git diff emits nothing for untracked paths). Each content line is prefixed
|
|
100
|
+
* with `+` so {@link classifyDiff} colors it as an addition.
|
|
101
|
+
*/
|
|
102
|
+
export function untrackedDiffText(content: string): string {
|
|
103
|
+
const lines = content.split("\n");
|
|
104
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
105
|
+
return lines.map((l) => "+" + l).join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** PURE — clamp a selection index into `[0, count)` (0 when the list is empty). */
|
|
109
|
+
export function clampSel(sel: number, count: number): number {
|
|
110
|
+
if (count <= 0) return 0;
|
|
111
|
+
return Math.max(0, Math.min(sel, count - 1));
|
|
112
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, io-free helpers for the built-in file editor panel (M18.2).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately imports NOTHING from @opentui/core: the editing ENGINE is a
|
|
5
|
+
* native `EditBuffer` (bun:ffi), which cannot load under Node/vitest. Everything
|
|
6
|
+
* that can be unit-tested — binary sniffing, read-only classification, the
|
|
7
|
+
* line-number gutter, viewport slicing, and click→cursor coordinate math —
|
|
8
|
+
* lives here so it runs green under the Node test runner while app.tsx keeps
|
|
9
|
+
* the bun-only EditBuffer wiring.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Files at or above this byte length open read-only (no full-buffer edits on
|
|
13
|
+
* the render loop). */
|
|
14
|
+
export const MAX_EDITABLE_BYTES = 1_000_000;
|
|
15
|
+
/** How many leading bytes we sniff for a NUL before calling a file binary. */
|
|
16
|
+
export const BINARY_SNIFF_BYTES = 8000;
|
|
17
|
+
|
|
18
|
+
/** NUL-byte sniff: a single 0x00 in the leading window means "not text". */
|
|
19
|
+
export function isBinary(bytes: Uint8Array): boolean {
|
|
20
|
+
const n = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
21
|
+
for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ReadOnlyReason = "binary" | "large" | null;
|
|
26
|
+
|
|
27
|
+
/** Decide whether a freshly-read file is editable, and why not. */
|
|
28
|
+
export function classifyFile(byteLength: number, binary: boolean): ReadOnlyReason {
|
|
29
|
+
if (binary) return "binary";
|
|
30
|
+
if (byteLength >= MAX_EDITABLE_BYTES) return "large";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Human banner for a read-only reason (null = editable, no banner). */
|
|
35
|
+
export function readOnlyBanner(reason: ReadOnlyReason): string | null {
|
|
36
|
+
if (reason === "binary") return "read-only · binary file (null byte detected)";
|
|
37
|
+
if (reason === "large") return `read-only · file ≥ ${MAX_EDITABLE_BYTES / 1_000_000} MB`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Render a binary/undisplayable buffer as a safe ASCII preview: control and
|
|
42
|
+
* high bytes become "·" so the rope never carries NULs and the panel stays
|
|
43
|
+
* legible. Newlines/tabs are preserved so line structure survives. */
|
|
44
|
+
export function sanitizeForDisplay(bytes: Uint8Array): string {
|
|
45
|
+
let out = "";
|
|
46
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
+
const b = bytes[i]!;
|
|
48
|
+
if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
|
|
49
|
+
out += String.fromCharCode(b);
|
|
50
|
+
} else {
|
|
51
|
+
out += "·";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Total width of the line-number gutter, including its trailing space. */
|
|
58
|
+
export function gutterWidth(totalLines: number): number {
|
|
59
|
+
return Math.max(3, String(Math.max(1, totalLines)).length) + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Right-aligned line number filling `width` (last column is the separator). */
|
|
63
|
+
export function formatGutter(lineNumber: number, width: number): string {
|
|
64
|
+
return String(lineNumber).padStart(width - 1, " ") + " ";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Clamp the top (first-visible) line so the viewport never scrolls past the
|
|
68
|
+
* last screenful. `rows` is the number of text rows on screen. */
|
|
69
|
+
export function clampTop(top: number, totalLines: number, rows: number): number {
|
|
70
|
+
const max = Math.max(0, totalLines - rows);
|
|
71
|
+
if (top < 0) return 0;
|
|
72
|
+
if (top > max) return max;
|
|
73
|
+
return top;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The [start,end) line range visible for a given scroll position. */
|
|
77
|
+
export function visibleRange(
|
|
78
|
+
totalLines: number,
|
|
79
|
+
top: number,
|
|
80
|
+
rows: number,
|
|
81
|
+
): { start: number; end: number } {
|
|
82
|
+
const start = clampTop(top, totalLines, rows);
|
|
83
|
+
return { start, end: Math.min(totalLines, start + rows) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Ensure the cursor line sits inside [top, top+rows); returns the new top. */
|
|
87
|
+
export function scrollToCursor(
|
|
88
|
+
cursorLine: number,
|
|
89
|
+
top: number,
|
|
90
|
+
rows: number,
|
|
91
|
+
totalLines: number,
|
|
92
|
+
): number {
|
|
93
|
+
let next = top;
|
|
94
|
+
if (cursorLine < top) next = cursorLine;
|
|
95
|
+
else if (cursorLine >= top + rows) next = cursorLine - rows + 1;
|
|
96
|
+
return clampTop(next, totalLines, rows);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Map a click at content-relative coordinates to a buffer (line, col).
|
|
101
|
+
* `contentY` is rows below the editor header; `cx` is columns right of the
|
|
102
|
+
* sidebar (gutter included). Both results are clamped to the buffer.
|
|
103
|
+
*/
|
|
104
|
+
export function clickToCursor(params: {
|
|
105
|
+
cx: number;
|
|
106
|
+
contentY: number;
|
|
107
|
+
gutterW: number;
|
|
108
|
+
top: number;
|
|
109
|
+
lines: string[];
|
|
110
|
+
}): { line: number; col: number } {
|
|
111
|
+
const { cx, contentY, gutterW, top, lines } = params;
|
|
112
|
+
const total = Math.max(1, lines.length);
|
|
113
|
+
const line = Math.max(0, Math.min(total - 1, top + contentY));
|
|
114
|
+
const lineText = lines[line] ?? "";
|
|
115
|
+
const col = Math.max(0, Math.min(lineText.length, cx - gutterW));
|
|
116
|
+
return { line, col };
|
|
117
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure model for the Files tab's one-level-expandable file list (M18.4). Like
|
|
3
|
+
* {@link ./diff-model.ts}, the io (async `fs.readdir`) stays in app.tsx; the
|
|
4
|
+
* ORDERING and the flat-tree splice/prune math live here so they unit-test as
|
|
5
|
+
* tables.
|
|
6
|
+
*
|
|
7
|
+
* The list is a FLAT array of {@link FileNode}s carrying a `depth`; a directory
|
|
8
|
+
* "expands" by splicing its freshly-read children in right after it and
|
|
9
|
+
* "collapses" by removing the contiguous run of deeper rows. Dirs sort before
|
|
10
|
+
* files, each group alphabetical (case-insensitive), so the tree reads the way
|
|
11
|
+
* a file manager does.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One row in the flat file list. `path` is absolute; `depth` is the indent
|
|
15
|
+
* level (0 = the context dir's immediate children). */
|
|
16
|
+
export interface FileNode {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
isDir: boolean;
|
|
20
|
+
depth: number;
|
|
21
|
+
expanded: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A raw `fs.readdir(..., { withFileTypes: true })` entry, reduced to what we
|
|
25
|
+
* need (kept minimal so callers can map Dirent → this trivially). */
|
|
26
|
+
export interface RawEntry {
|
|
27
|
+
name: string;
|
|
28
|
+
isDir: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PURE — sort raw entries into display order: directories first, then files,
|
|
33
|
+
* each group alphabetical case-insensitively (ties broken by raw name for
|
|
34
|
+
* determinism). Dotfiles are kept (a hidden `.env` still matters to editing).
|
|
35
|
+
*/
|
|
36
|
+
export function sortEntries(entries: RawEntry[]): RawEntry[] {
|
|
37
|
+
return [...entries].sort((a, b) => {
|
|
38
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
39
|
+
const an = a.name.toLowerCase();
|
|
40
|
+
const bn = b.name.toLowerCase();
|
|
41
|
+
if (an < bn) return -1;
|
|
42
|
+
if (an > bn) return 1;
|
|
43
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* PURE — build the flat node list for a directory's `entries` at `depth`,
|
|
49
|
+
* joining each name onto `dir` with a "/" (absolute-path aware; no node:path
|
|
50
|
+
* dependency so this stays trivially testable).
|
|
51
|
+
*/
|
|
52
|
+
export function buildNodes(dir: string, entries: RawEntry[], depth: number): FileNode[] {
|
|
53
|
+
const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
54
|
+
return sortEntries(entries).map((e) => ({
|
|
55
|
+
name: e.name,
|
|
56
|
+
path: `${base}/${e.name}`,
|
|
57
|
+
isDir: e.isDir,
|
|
58
|
+
depth,
|
|
59
|
+
expanded: false,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PURE — expand the directory at `index` by marking it expanded and splicing
|
|
65
|
+
* its `children` (already built at the parent's depth+1) in right after it.
|
|
66
|
+
* A no-op (returns the list unchanged) when the row is missing, is not a
|
|
67
|
+
* directory, or is already expanded.
|
|
68
|
+
*/
|
|
69
|
+
export function insertChildrenAt(
|
|
70
|
+
list: FileNode[],
|
|
71
|
+
index: number,
|
|
72
|
+
children: FileNode[],
|
|
73
|
+
): FileNode[] {
|
|
74
|
+
const parent = list[index];
|
|
75
|
+
if (!parent || !parent.isDir || parent.expanded) return list;
|
|
76
|
+
const next = [...list];
|
|
77
|
+
next[index] = { ...parent, expanded: true };
|
|
78
|
+
next.splice(index + 1, 0, ...children);
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* PURE — collapse the directory at `index` by marking it un-expanded and
|
|
84
|
+
* removing the contiguous run of rows deeper than it (its whole subtree,
|
|
85
|
+
* however many levels were expanded). A no-op when the row is missing, is not a
|
|
86
|
+
* directory, or is not expanded.
|
|
87
|
+
*/
|
|
88
|
+
export function removeSubtreeAt(list: FileNode[], index: number): FileNode[] {
|
|
89
|
+
const parent = list[index];
|
|
90
|
+
if (!parent || !parent.isDir || !parent.expanded) return list;
|
|
91
|
+
let end = index + 1;
|
|
92
|
+
while (end < list.length && list[end]!.depth > parent.depth) end++;
|
|
93
|
+
const next = [...list];
|
|
94
|
+
next.splice(index + 1, end - (index + 1));
|
|
95
|
+
next[index] = { ...parent, expanded: false };
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus-border geometry (M22.7): the accent strips drawn in the canvas GUTTER
|
|
3
|
+
* cells around the focused pane, so focus is pane-level obvious without
|
|
4
|
+
* consuming any terminal cells (a real box border would shrink the grid).
|
|
5
|
+
*
|
|
6
|
+
* Strips exist only where the pane has a neighboring gutter INSIDE the canvas —
|
|
7
|
+
* a pane flush to a canvas edge has no room on that side (tmux gives edge panes
|
|
8
|
+
* flush edges), and a single-pane window renders no strips at all (there is no
|
|
9
|
+
* ambiguity to resolve, and no gutters to paint). Horizontal strips extend one
|
|
10
|
+
* cell into the adjacent vertical gutters so the corners fill.
|
|
11
|
+
*
|
|
12
|
+
* PURE — rect math only; the app renders each strip as one background-tinted
|
|
13
|
+
* box, and the strips are handler-less so gutter presses still bubble to the
|
|
14
|
+
* central router (border drags keep working).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface PaneRectLike {
|
|
18
|
+
left: number;
|
|
19
|
+
top: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FocusStrip {
|
|
25
|
+
left: number;
|
|
26
|
+
top: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The gutter strips around `pane` within a `cols`×`rows` canvas — empty when
|
|
33
|
+
* the window has a single pane (`paneCount < 2`) or the rect is degenerate.
|
|
34
|
+
*/
|
|
35
|
+
export function focusStrips(
|
|
36
|
+
pane: PaneRectLike,
|
|
37
|
+
cols: number,
|
|
38
|
+
rows: number,
|
|
39
|
+
paneCount: number,
|
|
40
|
+
): FocusStrip[] {
|
|
41
|
+
if (paneCount < 2) return [];
|
|
42
|
+
if (pane.width <= 0 || pane.height <= 0) return [];
|
|
43
|
+
const strips: FocusStrip[] = [];
|
|
44
|
+
const hasLeft = pane.left > 0;
|
|
45
|
+
const hasRight = pane.left + pane.width < cols;
|
|
46
|
+
const hasTop = pane.top > 0;
|
|
47
|
+
const hasBottom = pane.top + pane.height < rows;
|
|
48
|
+
// Horizontal strips reach into the side gutters so corners fill.
|
|
49
|
+
const hx = hasLeft ? pane.left - 1 : pane.left;
|
|
50
|
+
const hw = pane.width + (hasLeft ? 1 : 0) + (hasRight ? 1 : 0);
|
|
51
|
+
if (hasTop) strips.push({ left: hx, top: pane.top - 1, width: hw, height: 1 });
|
|
52
|
+
if (hasBottom) strips.push({ left: hx, top: pane.top + pane.height, width: hw, height: 1 });
|
|
53
|
+
if (hasLeft) strips.push({ left: pane.left - 1, top: pane.top, width: 1, height: pane.height });
|
|
54
|
+
if (hasRight)
|
|
55
|
+
strips.push({ left: pane.left + pane.width, top: pane.top, width: 1, height: pane.height });
|
|
56
|
+
return strips;
|
|
57
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "open a folder" picker's PURE model (M22.5) — the filesystem browser
|
|
3
|
+
* behind the home screen's non-technical entry point. The picker is a
|
|
4
|
+
* {@link ../mirror/dialog-stack.ts} DialogSelect flow: app.tsx does the async
|
|
5
|
+
* `readdir`/`stat` (the header's async-only fs law) and drives the browse loop;
|
|
6
|
+
* everything here — how a directory's entries become rows, how the breadcrumb
|
|
7
|
+
* reads, how a typed path expands, how an invalid path is explained — is pure so
|
|
8
|
+
* it unit-tests without OpenTUI or a filesystem.
|
|
9
|
+
*
|
|
10
|
+
* Row id scheme (what a chosen row dispatches on):
|
|
11
|
+
* picker:open → open the folder we are browsing
|
|
12
|
+
* picker:hidden → toggle whether dot-leading folders show
|
|
13
|
+
* picker:up → ascend to the parent
|
|
14
|
+
* picker:type → the "type a path…" escape hatch (a DialogPrompt)
|
|
15
|
+
* picker:dir:<name> → descend into <name>
|
|
16
|
+
*
|
|
17
|
+
* The hidden-folders toggle is a ROW (not a ctrl chord): terminal-native,
|
|
18
|
+
* `ctrl+h` is byte-identical to Backspace, so a chord can't carry it — and a
|
|
19
|
+
* row is mouse-clickable and keyboard-selectable, which suits the audience.
|
|
20
|
+
*/
|
|
21
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import type { DialogSelectItem } from "./dialog-model.ts";
|
|
23
|
+
|
|
24
|
+
export const PICKER_OPEN_ID = "picker:open";
|
|
25
|
+
export const PICKER_HIDDEN_ID = "picker:hidden";
|
|
26
|
+
export const PICKER_UP_ID = "picker:up";
|
|
27
|
+
export const PICKER_TYPE_ID = "picker:type";
|
|
28
|
+
export const PICKER_DIR_PREFIX = "picker:dir:";
|
|
29
|
+
|
|
30
|
+
/** How many breadcrumb segments the title keeps before eliding the front with
|
|
31
|
+
* an ellipsis (deep paths stay one readable line). */
|
|
32
|
+
const BREADCRUMB_MAX_SEGMENTS = 4;
|
|
33
|
+
|
|
34
|
+
/** PURE — expand a user-typed path: a leading `~` (or `~/…`) becomes `home`,
|
|
35
|
+
* then relative paths resolve against `base`. An absolute path is returned as
|
|
36
|
+
* given (normalized). Blank input resolves to `base`. */
|
|
37
|
+
export function expandUserPath(input: string, home: string, base: string): string {
|
|
38
|
+
const raw = input.trim();
|
|
39
|
+
if (raw.length === 0) return base;
|
|
40
|
+
if (raw === "~") return home;
|
|
41
|
+
if (raw.startsWith("~/")) return join(home, raw.slice(2));
|
|
42
|
+
if (isAbsolute(raw)) return resolve(raw);
|
|
43
|
+
return resolve(base, raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** PURE — the parent directory, stopping at the filesystem root (dirname of
|
|
47
|
+
* `/` is `/`). */
|
|
48
|
+
export function pickerParent(dir: string): string {
|
|
49
|
+
return dirname(dir);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** PURE — whether `dir` is the filesystem root (no higher parent). */
|
|
53
|
+
export function isPickerRoot(dir: string): boolean {
|
|
54
|
+
return dirname(dir) === dir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PURE — the subdirectory names to show: hidden (dot-leading) folders are
|
|
58
|
+
* dropped unless `showHidden`, `.`/`..` never appear, and the rest sort
|
|
59
|
+
* case-insensitively so the list reads like a file manager. */
|
|
60
|
+
export function filterDirs(names: readonly string[], showHidden: boolean): string[] {
|
|
61
|
+
return names
|
|
62
|
+
.filter((n) => n !== "." && n !== "..")
|
|
63
|
+
.filter((n) => showHidden || !n.startsWith("."))
|
|
64
|
+
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** PURE — a compact breadcrumb for the dialog title: the home prefix collapses
|
|
68
|
+
* to `~`, segments join with ` › `, and a path deeper than
|
|
69
|
+
* {@link BREADCRUMB_MAX_SEGMENTS} elides its front with `…`. The root shows as
|
|
70
|
+
* `/`. */
|
|
71
|
+
export function pickerBreadcrumb(dir: string, home: string): string {
|
|
72
|
+
let rest = dir;
|
|
73
|
+
let head = "";
|
|
74
|
+
if (home.length > 0 && (dir === home || dir.startsWith(`${home}/`))) {
|
|
75
|
+
head = "~";
|
|
76
|
+
rest = dir.slice(home.length);
|
|
77
|
+
}
|
|
78
|
+
const segs = rest.split("/").filter(Boolean);
|
|
79
|
+
const parts = head ? [head, ...segs] : segs;
|
|
80
|
+
if (parts.length === 0) return "/";
|
|
81
|
+
if (parts.length <= BREADCRUMB_MAX_SEGMENTS) return parts.join(" › ");
|
|
82
|
+
return ["…", ...parts.slice(parts.length - BREADCRUMB_MAX_SEGMENTS)].join(" › ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** PURE — the picker rows for a directory: an explicit "open this folder" row
|
|
86
|
+
* first, then the hidden-folders toggle, the ascend row (unless at the root),
|
|
87
|
+
* the subdirectories (already filtered/sorted by {@link filterDirs}), and the
|
|
88
|
+
* "type a path…" escape hatch last. Descending happens on a `picker:dir:*` /
|
|
89
|
+
* `picker:up` row; opening on `picker:open`; the toggle flips `showHidden`. */
|
|
90
|
+
export function pickerRows(
|
|
91
|
+
dir: string,
|
|
92
|
+
subdirs: readonly string[],
|
|
93
|
+
showHidden: boolean,
|
|
94
|
+
): DialogSelectItem[] {
|
|
95
|
+
const rows: DialogSelectItem[] = [
|
|
96
|
+
{ id: PICKER_OPEN_ID, label: "+ Open this folder", detail: basename(dir) || dir },
|
|
97
|
+
{
|
|
98
|
+
id: PICKER_HIDDEN_ID,
|
|
99
|
+
label: showHidden ? "Hide hidden folders" : "Show hidden folders",
|
|
100
|
+
detail: showHidden ? "on" : "off",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
if (!isPickerRoot(dir)) rows.push({ id: PICKER_UP_ID, label: "..", detail: "up a level" });
|
|
104
|
+
for (const name of subdirs) rows.push({ id: `${PICKER_DIR_PREFIX}${name}`, label: `${name}/` });
|
|
105
|
+
rows.push({ id: PICKER_TYPE_ID, label: "Type a path…", detail: "enter a folder path" });
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** PURE — the subdirectory name a `picker:dir:*` row descends into, or null for
|
|
110
|
+
* any other id. */
|
|
111
|
+
export function pickerDirName(id: string): string | null {
|
|
112
|
+
return id.startsWith(PICKER_DIR_PREFIX) ? id.slice(PICKER_DIR_PREFIX.length) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The three outcomes of stat-ing a typed path. */
|
|
116
|
+
export type PathKind = "dir" | "file" | "missing";
|
|
117
|
+
|
|
118
|
+
/** PURE — the plain-language footer error for a typed path that isn't a folder
|
|
119
|
+
* (never called for `"dir"` — that path is accepted). */
|
|
120
|
+
export function pathKindHint(kind: "file" | "missing"): string {
|
|
121
|
+
return kind === "file"
|
|
122
|
+
? "That's a file, not a folder — pick a directory"
|
|
123
|
+
: "No folder there — check the path and try again";
|
|
124
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOME panel's row model (M21.9) — PURE so it unit-tests without OpenTUI.
|
|
3
|
+
*
|
|
4
|
+
* Home used to be a flat list of live sessions; now it is a list of ITEMS:
|
|
5
|
+
* every live session (unchanged, first), then — when the project registry has
|
|
6
|
+
* projects with no live session — a section header followed by one launchable
|
|
7
|
+
* row per registered-but-not-running project. That surfaces the registry as a
|
|
8
|
+
* mouse-first "open project" affordance: clicking (or pressing enter on) a
|
|
9
|
+
* project row launches a detached session in its dir and opens it as the
|
|
10
|
+
* workspace.
|
|
11
|
+
*
|
|
12
|
+
* The item list is what the render walks AND what the router's `gy - 2` row
|
|
13
|
+
* math indexes, so selection/hover/click all share one geometry. Headers are
|
|
14
|
+
* not selectable; the step/clamp helpers keep the keyboard selection on real
|
|
15
|
+
* rows without the app hand-rolling skip loops.
|
|
16
|
+
*/
|
|
17
|
+
import { basename } from "node:path";
|
|
18
|
+
import type { AgentStatus } from "../detect/classify.ts";
|
|
19
|
+
import type { AppKeys } from "../../lib/app-config.ts";
|
|
20
|
+
import { prefixTwinFor } from "./settings-model.ts";
|
|
21
|
+
|
|
22
|
+
/** A live tmux session row — click/enter opens it as the workspace. */
|
|
23
|
+
export interface HomeSessionItem {
|
|
24
|
+
kind: "session";
|
|
25
|
+
session: string;
|
|
26
|
+
project: string;
|
|
27
|
+
status: AgentStatus;
|
|
28
|
+
windows: number;
|
|
29
|
+
dir: string | null;
|
|
30
|
+
}
|
|
31
|
+
/** A registered project with no live session — click/enter launches it. */
|
|
32
|
+
export interface HomeProjectItem {
|
|
33
|
+
kind: "project";
|
|
34
|
+
name: string;
|
|
35
|
+
dir: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** A recently-opened folder with no live session or registry entry (M22.5) —
|
|
38
|
+
* click/enter re-opens it (create-or-attach a session in `dir`). */
|
|
39
|
+
export interface HomeRecentItem {
|
|
40
|
+
kind: "recent";
|
|
41
|
+
name: string;
|
|
42
|
+
dir: string;
|
|
43
|
+
}
|
|
44
|
+
/** A non-selectable section label between the sessions and the registry. */
|
|
45
|
+
export interface HomeHeaderItem {
|
|
46
|
+
kind: "header";
|
|
47
|
+
label: string;
|
|
48
|
+
}
|
|
49
|
+
export type HomeItem = HomeSessionItem | HomeProjectItem | HomeRecentItem | HomeHeaderItem;
|
|
50
|
+
|
|
51
|
+
/** The slice of the `tmux-ide team --json` payload this model reads (kept
|
|
52
|
+
* structural so the app's locally-declared fleet shape satisfies it). */
|
|
53
|
+
export interface HomeFleetProject {
|
|
54
|
+
name: string;
|
|
55
|
+
dir: string | null;
|
|
56
|
+
registered: boolean;
|
|
57
|
+
running: boolean;
|
|
58
|
+
sessions: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
status: AgentStatus;
|
|
61
|
+
windows: Array<unknown>;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const REGISTRY_HEADER_LABEL = "registered projects — not running";
|
|
66
|
+
export const RECENTS_HEADER_LABEL = "recently opened";
|
|
67
|
+
|
|
68
|
+
/** PURE — the ordered home items: every project's live sessions first (the
|
|
69
|
+
* exact rows home always showed), then a header + one row per registered
|
|
70
|
+
* project that has no live session, then (M22.5) a "recently opened" section.
|
|
71
|
+
* Recents dedupe against the REGISTRY (registry wins) — a folder you saved as
|
|
72
|
+
* a project shows only as its project row, never doubled as a recent. Recents
|
|
73
|
+
* are NOT deduped against live sessions: opening a folder spins up a session,
|
|
74
|
+
* yet the folder still belongs in "recent" for one-click reopen later. */
|
|
75
|
+
export function buildHomeItems(
|
|
76
|
+
projects: readonly HomeFleetProject[],
|
|
77
|
+
recents: readonly string[] = [],
|
|
78
|
+
): HomeItem[] {
|
|
79
|
+
const items: HomeItem[] = [];
|
|
80
|
+
for (const p of projects) {
|
|
81
|
+
for (const s of p.sessions) {
|
|
82
|
+
items.push({
|
|
83
|
+
kind: "session",
|
|
84
|
+
session: s.name,
|
|
85
|
+
project: p.name,
|
|
86
|
+
status: s.status,
|
|
87
|
+
windows: s.windows.length,
|
|
88
|
+
dir: p.dir,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const idle = projects.filter((p) => p.registered && !p.running);
|
|
93
|
+
if (idle.length > 0) {
|
|
94
|
+
items.push({ kind: "header", label: REGISTRY_HEADER_LABEL });
|
|
95
|
+
for (const p of idle) items.push({ kind: "project", name: p.name, dir: p.dir });
|
|
96
|
+
}
|
|
97
|
+
// A folder already saved as a registered project is not repeated under
|
|
98
|
+
// "recent" (registry wins the dedupe).
|
|
99
|
+
const registeredDirs = new Set<string>();
|
|
100
|
+
for (const p of projects) if (p.dir && p.registered) registeredDirs.add(p.dir);
|
|
101
|
+
const freshRecents = recents.filter((d) => d.length > 0 && !registeredDirs.has(d));
|
|
102
|
+
if (freshRecents.length > 0) {
|
|
103
|
+
items.push({ kind: "header", label: RECENTS_HEADER_LABEL });
|
|
104
|
+
for (const dir of freshRecents) {
|
|
105
|
+
items.push({ kind: "recent", name: basename(dir) || dir, dir });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** PURE — first-run when the fleet has no live sessions AND no registered
|
|
112
|
+
* projects (M22.5). Recently-opened folders alone do NOT count — the welcome
|
|
113
|
+
* still greets someone who has only browsed folders, and hides the moment a
|
|
114
|
+
* session or project exists. */
|
|
115
|
+
export function isFirstRun(projects: readonly HomeFleetProject[]): boolean {
|
|
116
|
+
const hasSession = projects.some((p) => p.sessions.length > 0);
|
|
117
|
+
const hasProject = projects.some((p) => p.registered);
|
|
118
|
+
return !hasSession && !hasProject;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** PURE — the one-line first-run tip built from the USER'S ACTUAL keybindings
|
|
122
|
+
* (M22.5): the reliable `prefix <letter>` twin for each action, falling back
|
|
123
|
+
* to the raw Alt key when a letter is claimed by a stock tmux bind. */
|
|
124
|
+
export function firstRunTip(keys: AppKeys): string {
|
|
125
|
+
const k = (alt: string): string => {
|
|
126
|
+
const twin = prefixTwinFor(alt);
|
|
127
|
+
return twin ? `prefix ${twin}` : alt;
|
|
128
|
+
};
|
|
129
|
+
return `Your keys: ${k(keys.home)} home · ${k(keys.popup)} switch sessions · ${k(keys.menu)} actions`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** PURE — the leading-space padding that horizontally centers a `len`-wide run
|
|
133
|
+
* in a `width`-wide area (never negative). Shared by the welcome render and
|
|
134
|
+
* the click router so a centered clickable lands where it is drawn. */
|
|
135
|
+
export function centerPad(width: number, len: number): number {
|
|
136
|
+
return Math.max(0, Math.floor((width - len) / 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** PURE — whether an item takes selection / clicks as a row. */
|
|
140
|
+
export function isSelectable(item: HomeItem | undefined): boolean {
|
|
141
|
+
return item !== undefined && item.kind !== "header";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** PURE — clamp a selection index onto a selectable item: into range first,
|
|
145
|
+
* then the nearest selectable at-or-above, falling back downward. Returns 0
|
|
146
|
+
* for an empty list (matching the old `Math.min(sel, len - 1)` behavior). */
|
|
147
|
+
export function clampSelectable(items: readonly HomeItem[], sel: number): number {
|
|
148
|
+
if (items.length === 0) return 0;
|
|
149
|
+
const start = Math.max(0, Math.min(sel, items.length - 1));
|
|
150
|
+
for (let i = start; i < items.length; i++) if (isSelectable(items[i])) return i;
|
|
151
|
+
for (let i = start - 1; i >= 0; i--) if (isSelectable(items[i])) return i;
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** PURE — move the selection one selectable row in `delta`'s direction (±1),
|
|
156
|
+
* skipping headers; stays put at either end. */
|
|
157
|
+
export function stepSelectable(items: readonly HomeItem[], from: number, delta: 1 | -1): number {
|
|
158
|
+
for (let i = from + delta; i >= 0 && i < items.length; i += delta) {
|
|
159
|
+
if (isSelectable(items[i])) return i;
|
|
160
|
+
}
|
|
161
|
+
return from;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** PURE — a tmux-legal session name for a project: tmux forbids `:` and `.`
|
|
165
|
+
* in session names (target syntax), and spaces only invite quoting bugs —
|
|
166
|
+
* all collapse to `-`. */
|
|
167
|
+
export function sessionNameFor(project: string): string {
|
|
168
|
+
return project.replace(/[.:\s]+/g, "-");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** PURE — whether a user-typed session name is directly usable. */
|
|
172
|
+
export function isValidSessionName(name: string): boolean {
|
|
173
|
+
return name.length > 0 && !/[.:\s]/.test(name);
|
|
174
|
+
}
|