@ohzw/worktree-command-tui 0.1.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 +88 -0
- package/dist/app.d.ts +21 -0
- package/dist/app.js +139 -0
- package/dist/components/ActionPanel.d.ts +10 -0
- package/dist/components/ActionPanel.js +129 -0
- package/dist/components/ContextBar.d.ts +5 -0
- package/dist/components/ContextBar.js +12 -0
- package/dist/components/Header.d.ts +6 -0
- package/dist/components/Header.js +5 -0
- package/dist/components/WorktreeList.d.ts +7 -0
- package/dist/components/WorktreeList.js +58 -0
- package/dist/core/command-runner.d.ts +10 -0
- package/dist/core/command-runner.js +38 -0
- package/dist/core/config.d.ts +14 -0
- package/dist/core/config.js +152 -0
- package/dist/core/git-worktrees.d.ts +11 -0
- package/dist/core/git-worktrees.js +60 -0
- package/dist/core/init.d.ts +22 -0
- package/dist/core/init.js +157 -0
- package/dist/core/posix-process.d.ts +4 -0
- package/dist/core/posix-process.js +42 -0
- package/dist/core/process-control.d.ts +11 -0
- package/dist/core/process-control.js +15 -0
- package/dist/core/runtime.d.ts +65 -0
- package/dist/core/runtime.js +263 -0
- package/dist/core/session-store.d.ts +21 -0
- package/dist/core/session-store.js +51 -0
- package/dist/core/validation.d.ts +1 -0
- package/dist/core/validation.js +14 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +73 -0
- package/dist/render-options.d.ts +4 -0
- package/dist/render-options.js +4 -0
- package/package.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# worktree-command-tui
|
|
2
|
+
|
|
3
|
+
`worktree-command-tui` is a terminal UI (TUI) tool for operating multiple Git worktrees from one repository.
|
|
4
|
+
It helps you inspect, start, and stop per-worktree processes with quick keyboard-driven workflows.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- List and monitor worktrees for the current repository
|
|
9
|
+
- Start/stop worktree-specific commands
|
|
10
|
+
- Keep process handling centralized for each session
|
|
11
|
+
- Persist namespace-aware runtime state
|
|
12
|
+
- Support JSONC config (with comments and trailing commas)
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Node.js `>=20`
|
|
17
|
+
- A Git repository in the target working directory
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @ohzw/worktree-command-tui
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1) Initialize configuration
|
|
28
|
+
|
|
29
|
+
Run this once in a repository root (or any subdirectory of the repository):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
wctui init
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This creates `.worktree-command-tui.jsonc` with a sensible default configuration.
|
|
36
|
+
|
|
37
|
+
To regenerate an existing configuration:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wctui init --force
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2) Start the TUI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
wctui
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`worktree-command-tui` is still available as a compatibility alias.
|
|
50
|
+
|
|
51
|
+
If no configuration file is found, the CLI will prompt you to run `wctui init`.
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
The tool reads these files in this order:
|
|
56
|
+
|
|
57
|
+
1. `.worktree-command-tui.jsonc`
|
|
58
|
+
2. `.worktree-command-tui.json`
|
|
59
|
+
|
|
60
|
+
A minimal example of the generated config:
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
// Session namespace used for logs/state
|
|
65
|
+
"namespace": "worktree-command-tui",
|
|
66
|
+
// Command executed in each selected worktree
|
|
67
|
+
"command": ["npm", "run", "start"],
|
|
68
|
+
// Port used for cleanup/monitoring
|
|
69
|
+
"port": 3000,
|
|
70
|
+
// Required files that must exist in a worktree
|
|
71
|
+
"requiredFiles": ["package.json"],
|
|
72
|
+
// Optional command substrings considered orphaned processes
|
|
73
|
+
"orphanMatchers": []
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
npm run test # Run test suite
|
|
82
|
+
npm run typecheck # Run TypeScript type-check
|
|
83
|
+
npm run build # Build distributable output to dist/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AppActions, AppModel } from './core/runtime.js';
|
|
2
|
+
export interface ShellDimensions {
|
|
3
|
+
rootWidth: number;
|
|
4
|
+
rootHeight: number;
|
|
5
|
+
bodyWidth: number;
|
|
6
|
+
listWidth: number;
|
|
7
|
+
actionWidth: number;
|
|
8
|
+
}
|
|
9
|
+
export interface AppWindowSize {
|
|
10
|
+
columns: number;
|
|
11
|
+
rows: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
|
|
14
|
+
export declare function shouldUseCompactLayout(columns: number, rows: number, worktreeCount?: number): boolean;
|
|
15
|
+
export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
|
|
16
|
+
export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
|
|
17
|
+
export declare function App({ initialModel, actions, windowSizeOverride, }: {
|
|
18
|
+
initialModel: AppModel;
|
|
19
|
+
actions: AppActions;
|
|
20
|
+
windowSizeOverride?: AppWindowSize;
|
|
21
|
+
}): import("react").JSX.Element;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import { ActionPanel } from './components/ActionPanel.js';
|
|
5
|
+
import { ContextBar } from './components/ContextBar.js';
|
|
6
|
+
import { Header } from './components/Header.js';
|
|
7
|
+
import { WorktreeList } from './components/WorktreeList.js';
|
|
8
|
+
function getNextSelectedPath(rows, currentPath) {
|
|
9
|
+
if (rows.length === 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (currentPath && rows.some(row => row.path === currentPath)) {
|
|
13
|
+
return currentPath;
|
|
14
|
+
}
|
|
15
|
+
return rows[0].path;
|
|
16
|
+
}
|
|
17
|
+
export function getShellDimensions(columns, rows) {
|
|
18
|
+
const rootWidth = Math.max(columns, 1);
|
|
19
|
+
const rootHeight = Math.max(rows, 1);
|
|
20
|
+
const bodyWidth = Math.max(rootWidth - 4, 1);
|
|
21
|
+
const maxListWidth = Math.max(1, bodyWidth - 21);
|
|
22
|
+
const desiredListWidth = Math.max(1, Math.floor((bodyWidth - 1) * 0.34));
|
|
23
|
+
const listWidth = Math.min(42, desiredListWidth, maxListWidth);
|
|
24
|
+
const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
|
|
25
|
+
return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
|
|
26
|
+
}
|
|
27
|
+
export function shouldUseCompactLayout(columns, rows, worktreeCount = 0) {
|
|
28
|
+
const contentAwareRowFloor = Math.max(20, worktreeCount + 12);
|
|
29
|
+
return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
|
|
30
|
+
}
|
|
31
|
+
export function shouldUseMinimalLayout(columns, rows) {
|
|
32
|
+
return columns < 20 || rows < 6;
|
|
33
|
+
}
|
|
34
|
+
export function shouldStackPanes(columns, rows, worktreeCount = 0) {
|
|
35
|
+
const minimumRows = Math.max(26, worktreeCount + 18);
|
|
36
|
+
return columns < 96 && rows >= minimumRows;
|
|
37
|
+
}
|
|
38
|
+
export function App({ initialModel, actions, windowSizeOverride, }) {
|
|
39
|
+
const { exit } = useApp();
|
|
40
|
+
const liveWindowSize = useWindowSize();
|
|
41
|
+
const { columns, rows } = windowSizeOverride ?? liveWindowSize;
|
|
42
|
+
const [model, setModel] = useState(initialModel);
|
|
43
|
+
const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
|
|
44
|
+
const inFlightRef = useRef(false);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
|
|
47
|
+
}, [model.rows]);
|
|
48
|
+
const selectedIndex = useMemo(() => {
|
|
49
|
+
if (selectedPath === null) {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const foundIndex = model.rows.findIndex(row => row.path === selectedPath);
|
|
53
|
+
return foundIndex >= 0 ? foundIndex : 0;
|
|
54
|
+
}, [model.rows, selectedPath]);
|
|
55
|
+
const selected = model.rows[selectedIndex];
|
|
56
|
+
const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
|
|
57
|
+
const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
|
|
58
|
+
const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
|
|
59
|
+
const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
|
|
60
|
+
const compactDetailPane = !stackedLayout && rootHeight <= 26;
|
|
61
|
+
function moveSelection(nextIndex) {
|
|
62
|
+
if (model.rows.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
|
|
66
|
+
}
|
|
67
|
+
async function apply(action) {
|
|
68
|
+
inFlightRef.current = true;
|
|
69
|
+
try {
|
|
70
|
+
const next = await action();
|
|
71
|
+
setModel(next);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
setModel(current => ({
|
|
75
|
+
...current,
|
|
76
|
+
status: {
|
|
77
|
+
kind: 'error',
|
|
78
|
+
message: error instanceof Error ? error.message : String(error),
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
inFlightRef.current = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
if (key.escape || input === 'q') {
|
|
88
|
+
exit();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key.upArrow || input === 'k') {
|
|
92
|
+
moveSelection(selectedIndex - 1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.downArrow || input === 'j') {
|
|
96
|
+
moveSelection(selectedIndex + 1);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (input === 'g') {
|
|
100
|
+
moveSelection(0);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (input === 'G') {
|
|
104
|
+
moveSelection(model.rows.length - 1);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (inFlightRef.current) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key.return && selected) {
|
|
111
|
+
if (selected.invalidReason) {
|
|
112
|
+
setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (selected.path === model.activePath) {
|
|
116
|
+
setModel(current => ({ ...current, status: { kind: 'idle', message: 'already active' } }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
|
|
120
|
+
void apply(() => actions.start(selected.path));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (input === 's') {
|
|
124
|
+
setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
|
|
125
|
+
void apply(() => actions.stop());
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (input === 'r') {
|
|
129
|
+
void apply(() => actions.refresh());
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
if (minimalLayout) {
|
|
133
|
+
return (_jsxs(Box, { width: rootWidth, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5q" }) : null] }));
|
|
134
|
+
}
|
|
135
|
+
if (compactLayout) {
|
|
136
|
+
return (_jsxs(Box, { width: rootWidth, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", model.status.kind, " \u2014 ", model.status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 s r q \u00B7 Resize terminal for split view" })] }));
|
|
137
|
+
}
|
|
138
|
+
return (_jsxs(Box, { width: rootWidth, height: stackedLayout ? undefined : rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, stacked: stackedLayout }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, compactDetails: compactDetailPane })] }), _jsx(ContextBar, { status: model.status })] }));
|
|
139
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AppRow } from '../core/runtime.js';
|
|
2
|
+
export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
|
|
3
|
+
export declare function getActionColor(selectedRow: AppRow): 'yellow' | 'red' | undefined;
|
|
4
|
+
export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
|
|
5
|
+
selectedRow: AppRow | undefined;
|
|
6
|
+
activePath: string | null;
|
|
7
|
+
stacked: boolean;
|
|
8
|
+
width?: number;
|
|
9
|
+
compactDetails?: boolean;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
function formatTags(tags) {
|
|
4
|
+
return tags.length === 0 ? '-' : tags.join(' · ');
|
|
5
|
+
}
|
|
6
|
+
function sanitizeInlineText(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/[\r\n\t\u2028\u2029]+/g, ' ')
|
|
9
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
|
10
|
+
.replace(/\p{Cf}/gu, '')
|
|
11
|
+
.replace(/\s+/g, ' ')
|
|
12
|
+
.trim();
|
|
13
|
+
}
|
|
14
|
+
function formatUpstream(selectedRow) {
|
|
15
|
+
if (selectedRow.upstreamUnavailable) {
|
|
16
|
+
return 'unavailable';
|
|
17
|
+
}
|
|
18
|
+
if (!selectedRow.upstream) {
|
|
19
|
+
return '-';
|
|
20
|
+
}
|
|
21
|
+
return `${sanitizeInlineText(selectedRow.upstream.branch)} (↑${selectedRow.upstream.ahead} ↓${selectedRow.upstream.behind})`;
|
|
22
|
+
}
|
|
23
|
+
function formatWorkingTree(selectedRow) {
|
|
24
|
+
if (!selectedRow.workingTree) {
|
|
25
|
+
return 'unavailable';
|
|
26
|
+
}
|
|
27
|
+
const { staged, unstaged, untracked, conflicts } = selectedRow.workingTree;
|
|
28
|
+
if (staged === 0 && unstaged === 0 && untracked === 0 && conflicts === 0) {
|
|
29
|
+
return 'clean';
|
|
30
|
+
}
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (staged > 0) {
|
|
33
|
+
parts.push(`index ${staged}`);
|
|
34
|
+
}
|
|
35
|
+
if (unstaged > 0) {
|
|
36
|
+
parts.push(`worktree ${unstaged}`);
|
|
37
|
+
}
|
|
38
|
+
if (untracked > 0) {
|
|
39
|
+
parts.push(`untracked ${untracked}`);
|
|
40
|
+
}
|
|
41
|
+
if (conflicts > 0) {
|
|
42
|
+
parts.push(`conflicts ${conflicts}`);
|
|
43
|
+
}
|
|
44
|
+
return `dirty (${parts.join(' · ')})`;
|
|
45
|
+
}
|
|
46
|
+
function formatPullRequest(selectedRow) {
|
|
47
|
+
if (!selectedRow.pullRequest || selectedRow.pullRequest.kind === 'none') {
|
|
48
|
+
return 'none';
|
|
49
|
+
}
|
|
50
|
+
if (selectedRow.pullRequest.kind === 'unavailable') {
|
|
51
|
+
return 'unavailable';
|
|
52
|
+
}
|
|
53
|
+
const draft = selectedRow.pullRequest.isDraft ? 'draft/' : '';
|
|
54
|
+
return `#${selectedRow.pullRequest.number} ${draft}${selectedRow.pullRequest.state.toLowerCase()} → ${sanitizeInlineText(selectedRow.pullRequest.baseBranch)}`;
|
|
55
|
+
}
|
|
56
|
+
function getPullRequestLabel(selectedRow) {
|
|
57
|
+
if (selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN') {
|
|
58
|
+
return 'Last PR';
|
|
59
|
+
}
|
|
60
|
+
return 'PR';
|
|
61
|
+
}
|
|
62
|
+
function getPullRequestTitleLabel(selectedRow) {
|
|
63
|
+
if (selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN') {
|
|
64
|
+
return 'Last PR Title';
|
|
65
|
+
}
|
|
66
|
+
return 'PR Title';
|
|
67
|
+
}
|
|
68
|
+
export function getPullRequestColor(selectedRow) {
|
|
69
|
+
if (!selectedRow.pullRequest || selectedRow.pullRequest.kind === 'none') {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (selectedRow.pullRequest.kind === 'unavailable') {
|
|
73
|
+
return 'red';
|
|
74
|
+
}
|
|
75
|
+
if (selectedRow.pullRequest.state !== 'OPEN') {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return selectedRow.pullRequest.isDraft ? 'yellow' : 'green';
|
|
79
|
+
}
|
|
80
|
+
function getActionMessage(selectedRow, activePath) {
|
|
81
|
+
if (selectedRow.invalidReason) {
|
|
82
|
+
return 'Cannot start this worktree.';
|
|
83
|
+
}
|
|
84
|
+
if (selectedRow.path === activePath) {
|
|
85
|
+
return 'Already active. Press s to stop the current session.';
|
|
86
|
+
}
|
|
87
|
+
return 'Press Enter to start here and switch the active session.';
|
|
88
|
+
}
|
|
89
|
+
export function getActionColor(selectedRow) {
|
|
90
|
+
if (selectedRow.invalidReason) {
|
|
91
|
+
return 'red';
|
|
92
|
+
}
|
|
93
|
+
if ((selectedRow.workingTree?.conflicts ?? 0) > 0) {
|
|
94
|
+
return 'red';
|
|
95
|
+
}
|
|
96
|
+
if ((selectedRow.workingTree?.staged ?? 0) > 0
|
|
97
|
+
|| (selectedRow.workingTree?.unstaged ?? 0) > 0
|
|
98
|
+
|| (selectedRow.workingTree?.untracked ?? 0) > 0) {
|
|
99
|
+
return 'yellow';
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function getNotes(selectedRow) {
|
|
104
|
+
if (selectedRow.invalidReason) {
|
|
105
|
+
return selectedRow.invalidReason;
|
|
106
|
+
}
|
|
107
|
+
if (selectedRow.tags.includes('external')) {
|
|
108
|
+
return 'External worktree managed outside the main checkout path.';
|
|
109
|
+
}
|
|
110
|
+
if (selectedRow.tags.includes('active')) {
|
|
111
|
+
return 'This worktree currently owns the running command session.';
|
|
112
|
+
}
|
|
113
|
+
return 'Ready to launch with the configured command in this worktree.';
|
|
114
|
+
}
|
|
115
|
+
function SectionHeader({ label }) {
|
|
116
|
+
return (_jsxs(Text, { bold: true, color: "cyan", children: ["[", label, "]"] }));
|
|
117
|
+
}
|
|
118
|
+
export function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }) {
|
|
119
|
+
if (!selectedRow) {
|
|
120
|
+
return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(Text, { dimColor: true, children: "No worktrees found." })] }));
|
|
121
|
+
}
|
|
122
|
+
const actionMessage = getActionMessage(selectedRow, activePath);
|
|
123
|
+
const showFullPath = !compactDetails && selectedRow.shortPath !== selectedRow.path;
|
|
124
|
+
const showTags = !compactDetails;
|
|
125
|
+
const pullRequestTitle = selectedRow.pullRequest?.kind === 'found' && !compactDetails
|
|
126
|
+
? sanitizeInlineText(selectedRow.pullRequest.title)
|
|
127
|
+
: null;
|
|
128
|
+
return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, flexShrink: 1, borderStyle: "round", borderColor: "magenta", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Selection / Action" }), _jsx(SectionHeader, { label: "Identity" }), _jsxs(Text, { bold: true, color: selectedRow.tags.includes('active') ? 'green' : undefined, wrap: "truncate-end", children: ["Branch: ", sanitizeInlineText(selectedRow.branch)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Path: ", sanitizeInlineText(selectedRow.shortPath)] }), showFullPath ? _jsxs(Text, { wrap: "truncate-end", children: ["Full Path: ", sanitizeInlineText(selectedRow.path)] }) : undefined, _jsxs(Text, { wrap: "truncate-end", children: ["HEAD: ", selectedRow.headSha || '-'] }), showTags ? _jsxs(Text, { wrap: "truncate-end", children: ["Tags: ", formatTags(selectedRow.tags)] }) : undefined, _jsx(SectionHeader, { label: "Git / PR" }), _jsxs(Text, { wrap: "truncate-end", children: ["Upstream: ", formatUpstream(selectedRow)] }), _jsxs(Text, { wrap: "truncate-end", children: ["Status: ", formatWorkingTree(selectedRow)] }), _jsxs(Text, { color: getPullRequestColor(selectedRow), dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestLabel(selectedRow), ": ", formatPullRequest(selectedRow)] }), pullRequestTitle ? _jsxs(Text, { dimColor: selectedRow.pullRequest?.kind === 'found' && selectedRow.pullRequest.state !== 'OPEN', wrap: "truncate-end", children: [getPullRequestTitleLabel(selectedRow), ": ", pullRequestTitle] }) : undefined, _jsx(SectionHeader, { label: "Action" }), _jsx(Text, { color: getActionColor(selectedRow), wrap: "truncate-end", children: actionMessage }), _jsx(SectionHeader, { label: "Notes" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: getNotes(selectedRow) })] }));
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const COLOR_BY_KIND = {
|
|
4
|
+
idle: 'blue',
|
|
5
|
+
starting: 'yellow',
|
|
6
|
+
running: 'green',
|
|
7
|
+
stopping: 'yellow',
|
|
8
|
+
error: 'red',
|
|
9
|
+
};
|
|
10
|
+
export function ContextBar({ status }) {
|
|
11
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: COLOR_BY_KIND[status.kind], flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: COLOR_BY_KIND[status.kind], wrap: "truncate-end", children: ["Status: ", status.kind, " \u2014 ", status.message] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk move g/G first/last Enter start/switch s stop r refresh q quit" })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function Header({ repoName, namespace, activeBranch, }) {
|
|
4
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "blue", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "blue", wrap: "truncate-end", children: ["Worktree Command TUI \u00B7 Repo: ", repoName] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["Active: ", activeBranch ?? '-'] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Namespace: ", namespace] })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const MIN_BRANCH_WIDTH = 24;
|
|
4
|
+
function sanitizeInlineText(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replace(/[\r\n\t\u2028\u2029]+/g, ' ')
|
|
7
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
|
|
8
|
+
.replace(/\p{Cf}/gu, '')
|
|
9
|
+
.replace(/\s+/g, ' ')
|
|
10
|
+
.trim();
|
|
11
|
+
}
|
|
12
|
+
function getIndicator(row) {
|
|
13
|
+
if (row.tags.includes('active')) {
|
|
14
|
+
return '*';
|
|
15
|
+
}
|
|
16
|
+
if (row.tags.includes('invalid')) {
|
|
17
|
+
return '!';
|
|
18
|
+
}
|
|
19
|
+
if (row.tags.includes('external')) {
|
|
20
|
+
return '^';
|
|
21
|
+
}
|
|
22
|
+
if (row.tags.includes('main')) {
|
|
23
|
+
return '#';
|
|
24
|
+
}
|
|
25
|
+
return '-';
|
|
26
|
+
}
|
|
27
|
+
function getRowColor(row, isSelected) {
|
|
28
|
+
if (row.tags.includes('active')) {
|
|
29
|
+
return 'green';
|
|
30
|
+
}
|
|
31
|
+
if (isSelected) {
|
|
32
|
+
return 'cyan';
|
|
33
|
+
}
|
|
34
|
+
if (row.tags.includes('invalid')) {
|
|
35
|
+
return 'red';
|
|
36
|
+
}
|
|
37
|
+
if (row.tags.includes('external')) {
|
|
38
|
+
return 'yellow';
|
|
39
|
+
}
|
|
40
|
+
if (row.tags.includes('main')) {
|
|
41
|
+
return 'blue';
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function truncateLabel(value, width) {
|
|
46
|
+
if (value.length <= width) {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
return `${value.slice(0, Math.max(width - 1, 0))}…`;
|
|
50
|
+
}
|
|
51
|
+
export function WorktreeList({ rows, selectedIndex, width, stacked, }) {
|
|
52
|
+
const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
|
|
53
|
+
return (_jsxs(Box, { width: width, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), rows.map((row, index) => {
|
|
54
|
+
const isSelected = index === selectedIndex;
|
|
55
|
+
const line = `${isSelected ? '>' : ' '} ${getIndicator(row)} ${truncateLabel(sanitizeInlineText(row.branch), branchWidth)}`;
|
|
56
|
+
return (_jsx(Text, { color: getRowColor(row, isSelected), dimColor: !isSelected && getRowColor(row, isSelected) === undefined, wrap: "truncate-end", children: line }, row.path));
|
|
57
|
+
})] }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function startDetachedCommand({ command, cwd, logsDir, logFileBase, }) {
|
|
5
|
+
mkdirSync(logsDir, { recursive: true });
|
|
6
|
+
const logPath = path.join(logsDir, `${logFileBase}.log`);
|
|
7
|
+
const fd = openSync(logPath, 'a');
|
|
8
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
9
|
+
let settled = false;
|
|
10
|
+
const child = spawn(command[0], command.slice(1), {
|
|
11
|
+
cwd,
|
|
12
|
+
detached: true,
|
|
13
|
+
stdio: ['ignore', fd, fd],
|
|
14
|
+
});
|
|
15
|
+
const finalize = () => {
|
|
16
|
+
if (settled) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
settled = true;
|
|
20
|
+
closeSync(fd);
|
|
21
|
+
};
|
|
22
|
+
child.once('error', error => {
|
|
23
|
+
finalize();
|
|
24
|
+
reject(error);
|
|
25
|
+
});
|
|
26
|
+
child.once('spawn', () => {
|
|
27
|
+
const pid = child.pid;
|
|
28
|
+
if (pid === undefined) {
|
|
29
|
+
finalize();
|
|
30
|
+
reject(new Error('spawn succeeded without pid'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
child.unref();
|
|
34
|
+
finalize();
|
|
35
|
+
resolve({ pid, pgid: pid, logPath });
|
|
36
|
+
});
|
|
37
|
+
return promise;
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const CONFIG_FILE_NAME = ".worktree-command-tui.jsonc";
|
|
2
|
+
export declare const LEGACY_CONFIG_FILE_NAME = ".worktree-command-tui.json";
|
|
3
|
+
export declare const CONFIG_FILE_NAMES: readonly [".worktree-command-tui.jsonc", ".worktree-command-tui.json"];
|
|
4
|
+
export interface ToolConfig {
|
|
5
|
+
namespace: string;
|
|
6
|
+
command: string[];
|
|
7
|
+
port: number;
|
|
8
|
+
requiredFiles: string[];
|
|
9
|
+
orphanMatchers: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function parseJsonc(source: string): unknown;
|
|
12
|
+
export declare function loadToolConfig({ repoRoot }: {
|
|
13
|
+
repoRoot: string;
|
|
14
|
+
}): Promise<ToolConfig>;
|