depth-first-thinking 2.1.7 → 2.1.9
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 +9 -5
- package/package.json +1 -1
- package/src/commands/open.ts +7 -0
- package/src/data/config.ts +47 -0
- package/src/data/operations.test.ts +37 -0
- package/src/data/operations.ts +54 -0
- package/src/data/types.ts +19 -1
- package/src/index.ts +7 -1
- package/src/tui/app.test.ts +3 -0
- package/src/tui/app.ts +248 -7
- package/src/tui/navigation.ts +89 -27
- package/src/utils/clipboard.ts +51 -0
- package/src/utils/platform.ts +4 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -29,21 +29,25 @@ bun add -g depth-first-thinking
|
|
|
29
29
|
### Default Behavior
|
|
30
30
|
|
|
31
31
|
Running `dft` without any arguments will:
|
|
32
|
-
-
|
|
32
|
+
- Reopen the **last opened project** if it still exists
|
|
33
|
+
- Otherwise, open the most frequently opened project if one exists
|
|
33
34
|
- Otherwise, list all projects
|
|
34
35
|
|
|
35
36
|
## Navigation
|
|
36
37
|
|
|
37
|
-
When you open a project, **Zen Mode** is
|
|
38
|
+
When you open a project, **Zen Mode** or **List Mode** is restored from your last session (default is Zen). In Zen Mode, only the currently selected task is displayed, while navigation still works across the full tree:
|
|
38
39
|
|
|
39
|
-
- `↑` `↓` - Move between sibling tasks at the current level
|
|
40
|
+
- `↑` `↓` - Move between sibling tasks at the current level (nodes with more children appear first)
|
|
40
41
|
- `→` `Space` `Enter` - Enter task / view its subtasks (move deeper in the hierarchy)
|
|
41
42
|
- `←` - Go back to parent task (move up in the hierarchy)
|
|
42
|
-
- `
|
|
43
|
+
- `Cmd+C` - Copy the selected task title to the clipboard
|
|
44
|
+
- `m` - Toggle between **Zen Mode** (single-task view) and **List Mode** (full list at current level); choice is remembered across projects
|
|
43
45
|
- `n` - New subtask
|
|
44
46
|
- `e` - Edit task title
|
|
45
47
|
- `d` - Toggle done status
|
|
46
|
-
- `x` - Delete task
|
|
48
|
+
- `x` - Delete task (confirmation defaults to Delete)
|
|
49
|
+
- `v` - Move the current task into another task
|
|
50
|
+
- `/` or `f` - Search tasks by title and jump to a result
|
|
47
51
|
- `q` - Quit
|
|
48
52
|
|
|
49
53
|
## Task Display
|
package/package.json
CHANGED
package/src/commands/open.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { saveConfig } from "../data/config";
|
|
1
2
|
import { loadProject, saveProject } from "../data/storage";
|
|
2
3
|
import type { Project } from "../data/types";
|
|
3
4
|
import { ExitCodes } from "../data/types";
|
|
@@ -28,6 +29,12 @@ export async function openCommand(projectName: string): Promise<void> {
|
|
|
28
29
|
console.error(error instanceof Error ? error.message : "Failed to update open count");
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
try {
|
|
33
|
+
await saveConfig({ lastOpenedProject: normalizedName });
|
|
34
|
+
} catch {
|
|
35
|
+
// Ignore config write failure
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
try {
|
|
32
39
|
await startTUI(project);
|
|
33
40
|
process.exit(ExitCodes.SUCCESS);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getConfigPath } from "../utils/platform";
|
|
4
|
+
import type { ViewMode } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface AppConfig {
|
|
7
|
+
viewMode?: ViewMode;
|
|
8
|
+
lastOpenedProject?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const defaults: AppConfig = {
|
|
12
|
+
viewMode: "zen",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function loadConfig(): Promise<AppConfig> {
|
|
16
|
+
const path = getConfigPath();
|
|
17
|
+
const file = Bun.file(path);
|
|
18
|
+
if (!(await file.exists())) {
|
|
19
|
+
return { ...defaults };
|
|
20
|
+
}
|
|
21
|
+
let data: unknown;
|
|
22
|
+
try {
|
|
23
|
+
data = await file.json();
|
|
24
|
+
} catch {
|
|
25
|
+
return { ...defaults };
|
|
26
|
+
}
|
|
27
|
+
if (!data || typeof data !== "object") {
|
|
28
|
+
return { ...defaults };
|
|
29
|
+
}
|
|
30
|
+
const obj = data as Record<string, unknown>;
|
|
31
|
+
return {
|
|
32
|
+
viewMode: obj.viewMode === "zen" || obj.viewMode === "list" ? obj.viewMode : defaults.viewMode,
|
|
33
|
+
lastOpenedProject:
|
|
34
|
+
typeof obj.lastOpenedProject === "string" ? obj.lastOpenedProject : undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function saveConfig(partial: Partial<AppConfig>): Promise<void> {
|
|
39
|
+
const path = getConfigPath();
|
|
40
|
+
const current = await loadConfig();
|
|
41
|
+
const merged: AppConfig = {
|
|
42
|
+
...current,
|
|
43
|
+
...partial,
|
|
44
|
+
};
|
|
45
|
+
await mkdir(dirname(path), { recursive: true });
|
|
46
|
+
await Bun.write(path, JSON.stringify(merged, null, 2));
|
|
47
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getSiblingIndex,
|
|
13
13
|
markNodeDone,
|
|
14
14
|
markNodeOpen,
|
|
15
|
+
moveNode,
|
|
15
16
|
toggleNodeStatus,
|
|
16
17
|
} from "./operations";
|
|
17
18
|
import type { Node } from "./types";
|
|
@@ -282,3 +283,39 @@ describe("getNodePath", () => {
|
|
|
282
283
|
expect(path?.[1]).toBe(child);
|
|
283
284
|
});
|
|
284
285
|
});
|
|
286
|
+
|
|
287
|
+
describe("moveNode", () => {
|
|
288
|
+
test("moves node to new parent", () => {
|
|
289
|
+
const root = createTestNode("Root", "root");
|
|
290
|
+
const a = addChildNode(root, "A");
|
|
291
|
+
const b = addChildNode(root, "B");
|
|
292
|
+
const c = addChildNode(a, "C");
|
|
293
|
+
|
|
294
|
+
const result = moveNode(root, c.id, b.id);
|
|
295
|
+
expect(result).toBe(true);
|
|
296
|
+
expect(root.children).toHaveLength(2);
|
|
297
|
+
expect(a.children).toHaveLength(0);
|
|
298
|
+
expect(b.children).toHaveLength(1);
|
|
299
|
+
expect(b.children[0]).toBe(c);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("rejects moving node into itself", () => {
|
|
303
|
+
const root = createTestNode("Root", "root");
|
|
304
|
+
const a = addChildNode(root, "A");
|
|
305
|
+
|
|
306
|
+
const result = moveNode(root, a.id, a.id);
|
|
307
|
+
expect(result).toBe(false);
|
|
308
|
+
expect(root.children).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("rejects moving node into its descendant", () => {
|
|
312
|
+
const root = createTestNode("Root", "root");
|
|
313
|
+
const a = addChildNode(root, "A");
|
|
314
|
+
const b = addChildNode(a, "B");
|
|
315
|
+
|
|
316
|
+
const result = moveNode(root, a.id, b.id);
|
|
317
|
+
expect(result).toBe(false);
|
|
318
|
+
expect(root.children).toHaveLength(1);
|
|
319
|
+
expect(a.children).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
});
|
package/src/data/operations.ts
CHANGED
|
@@ -109,3 +109,57 @@ export function countDescendants(node: Node): number {
|
|
|
109
109
|
}
|
|
110
110
|
return count;
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
export function moveNode(root: Node, nodeId: string, targetParentId: string): boolean {
|
|
114
|
+
const node = findNode(root, nodeId);
|
|
115
|
+
if (!node) return false;
|
|
116
|
+
|
|
117
|
+
const currentParent = findParent(root, nodeId);
|
|
118
|
+
if (!currentParent) return false;
|
|
119
|
+
|
|
120
|
+
const targetParent = findNode(root, targetParentId);
|
|
121
|
+
if (!targetParent) return false;
|
|
122
|
+
|
|
123
|
+
if (targetParentId === nodeId) return false;
|
|
124
|
+
|
|
125
|
+
const pathToTarget = buildPathToNode(root, targetParentId);
|
|
126
|
+
if (pathToTarget?.includes(nodeId)) return false;
|
|
127
|
+
|
|
128
|
+
const removed = deleteNode(currentParent, nodeId);
|
|
129
|
+
if (!removed) return false;
|
|
130
|
+
|
|
131
|
+
targetParent.children.push(node);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface NodeWithPath {
|
|
136
|
+
node: Node;
|
|
137
|
+
path: Node[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectNodesWithPaths(node: Node, pathSoFar: Node[], acc: NodeWithPath[]): void {
|
|
141
|
+
const path = pathSoFar.concat(node);
|
|
142
|
+
acc.push({ node, path });
|
|
143
|
+
for (const child of node.children) {
|
|
144
|
+
collectNodesWithPaths(child, path, acc);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getValidMoveTargets(root: Node, excludeNodeId: string): NodeWithPath[] {
|
|
149
|
+
const all: NodeWithPath[] = [];
|
|
150
|
+
collectNodesWithPaths(root, [], all);
|
|
151
|
+
return all.filter(({ node }) => {
|
|
152
|
+
if (node.id === excludeNodeId) return false;
|
|
153
|
+
const path = buildPathToNode(root, node.id);
|
|
154
|
+
return path && !path.includes(excludeNodeId);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function searchNodes(root: Node, query: string): NodeWithPath[] {
|
|
159
|
+
const q = query.trim().toLowerCase();
|
|
160
|
+
if (q.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const all: NodeWithPath[] = [];
|
|
163
|
+
collectNodesWithPaths(root, [], all);
|
|
164
|
+
return all.filter(({ node }) => node.title.toLowerCase().includes(q));
|
|
165
|
+
}
|
package/src/data/types.ts
CHANGED
|
@@ -18,13 +18,30 @@ export interface Project {
|
|
|
18
18
|
open_count?: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export type ModalType = "new" | "edit" | "delete" | "help";
|
|
21
|
+
export type ModalType = "new" | "edit" | "delete" | "help" | "move" | "search";
|
|
22
|
+
|
|
23
|
+
export interface MoveTarget {
|
|
24
|
+
nodeId: string;
|
|
25
|
+
title: string;
|
|
26
|
+
pathTitles: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SearchResult {
|
|
30
|
+
nodeId: string;
|
|
31
|
+
title: string;
|
|
32
|
+
pathTitles: string[];
|
|
33
|
+
}
|
|
22
34
|
|
|
23
35
|
export interface ModalState {
|
|
24
36
|
type: ModalType;
|
|
25
37
|
inputValue?: string;
|
|
26
38
|
errorMessage?: string;
|
|
27
39
|
selectedButton?: number;
|
|
40
|
+
moveTargets?: MoveTarget[];
|
|
41
|
+
selectedTargetIndex?: number;
|
|
42
|
+
nodeIdToMove?: string;
|
|
43
|
+
searchResults?: SearchResult[];
|
|
44
|
+
selectedResultIndex?: number;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
export type ViewMode = "list" | "zen";
|
|
@@ -33,6 +50,7 @@ export interface AppState {
|
|
|
33
50
|
project: Project;
|
|
34
51
|
navigationStack: string[];
|
|
35
52
|
selectedIndex: number;
|
|
53
|
+
selectedNodeId: string | null;
|
|
36
54
|
viewMode: ViewMode;
|
|
37
55
|
modalState: ModalState | null;
|
|
38
56
|
feedbackMessage: string | null;
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { newCommand } from "./commands/new";
|
|
|
7
7
|
import { openCommand } from "./commands/open";
|
|
8
8
|
import { treeCommand } from "./commands/tree";
|
|
9
9
|
import { updateCommand } from "./commands/update";
|
|
10
|
-
import {
|
|
10
|
+
import { loadConfig } from "./data/config";
|
|
11
|
+
import { getMostOpenedProject, projectExists } from "./data/storage";
|
|
11
12
|
import { isValidProjectName } from "./utils/validation";
|
|
12
13
|
import { VERSION } from "./version";
|
|
13
14
|
|
|
@@ -97,6 +98,11 @@ async function main() {
|
|
|
97
98
|
const args = process.argv.slice(2);
|
|
98
99
|
|
|
99
100
|
if (args.length === 0) {
|
|
101
|
+
const config = await loadConfig();
|
|
102
|
+
if (config.lastOpenedProject && (await projectExists(config.lastOpenedProject))) {
|
|
103
|
+
await openCommand(config.lastOpenedProject);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
100
106
|
const mostOpenedProject = await getMostOpenedProject();
|
|
101
107
|
if (mostOpenedProject) {
|
|
102
108
|
await openCommand(mostOpenedProject);
|
package/src/tui/app.test.ts
CHANGED
|
@@ -50,6 +50,7 @@ describe("viewMode and navigation state", () => {
|
|
|
50
50
|
project,
|
|
51
51
|
navigationStack: [],
|
|
52
52
|
selectedIndex: 0,
|
|
53
|
+
selectedNodeId: null,
|
|
53
54
|
viewMode: "zen",
|
|
54
55
|
modalState: null,
|
|
55
56
|
feedbackMessage: null,
|
|
@@ -69,6 +70,7 @@ describe("viewMode and navigation state", () => {
|
|
|
69
70
|
project,
|
|
70
71
|
navigationStack: [],
|
|
71
72
|
selectedIndex: 1,
|
|
73
|
+
selectedNodeId: "b",
|
|
72
74
|
viewMode: "zen",
|
|
73
75
|
modalState: null,
|
|
74
76
|
feedbackMessage: null,
|
|
@@ -88,6 +90,7 @@ describe("viewMode and navigation state", () => {
|
|
|
88
90
|
project,
|
|
89
91
|
navigationStack: [],
|
|
90
92
|
selectedIndex: 1,
|
|
93
|
+
selectedNodeId: "b",
|
|
91
94
|
viewMode: "zen",
|
|
92
95
|
modalState: null,
|
|
93
96
|
feedbackMessage: null,
|
package/src/tui/app.ts
CHANGED
|
@@ -7,16 +7,29 @@ import {
|
|
|
7
7
|
TextRenderable,
|
|
8
8
|
createCliRenderer,
|
|
9
9
|
} from "@opentui/core";
|
|
10
|
+
import { loadConfig, saveConfig } from "../data/config";
|
|
10
11
|
import {
|
|
11
12
|
addChildNode,
|
|
12
13
|
countDescendants,
|
|
13
14
|
deleteNode,
|
|
14
15
|
editNodeTitle,
|
|
15
16
|
findNode,
|
|
17
|
+
getValidMoveTargets,
|
|
18
|
+
moveNode,
|
|
19
|
+
searchNodes,
|
|
16
20
|
toggleNodeStatus,
|
|
17
21
|
} from "../data/operations";
|
|
18
22
|
import { saveProject } from "../data/storage";
|
|
19
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
AppState,
|
|
25
|
+
ModalState,
|
|
26
|
+
MoveTarget,
|
|
27
|
+
Node,
|
|
28
|
+
Project,
|
|
29
|
+
SearchResult,
|
|
30
|
+
ViewMode,
|
|
31
|
+
} from "../data/types";
|
|
32
|
+
import { writeToClipboard } from "../utils/clipboard";
|
|
20
33
|
import { truncate } from "../utils/formatting";
|
|
21
34
|
import { validateTitle } from "../utils/validation";
|
|
22
35
|
import {
|
|
@@ -31,6 +44,7 @@ import {
|
|
|
31
44
|
initializeNavigation,
|
|
32
45
|
moveDown,
|
|
33
46
|
moveUp,
|
|
47
|
+
navigateToNode,
|
|
34
48
|
} from "./navigation";
|
|
35
49
|
|
|
36
50
|
const colors = {
|
|
@@ -56,13 +70,14 @@ export class TUIApp {
|
|
|
56
70
|
private hintsText!: TextRenderable;
|
|
57
71
|
private modalContainer!: BoxRenderable | null;
|
|
58
72
|
|
|
59
|
-
constructor(renderer: CliRenderer, project: Project) {
|
|
73
|
+
constructor(renderer: CliRenderer, project: Project, initialViewMode?: ViewMode) {
|
|
60
74
|
this.renderer = renderer;
|
|
61
75
|
this.state = {
|
|
62
76
|
project,
|
|
63
77
|
navigationStack: [],
|
|
64
78
|
selectedIndex: 0,
|
|
65
|
-
|
|
79
|
+
selectedNodeId: null,
|
|
80
|
+
viewMode: initialViewMode ?? "zen",
|
|
66
81
|
modalState: null,
|
|
67
82
|
feedbackMessage: null,
|
|
68
83
|
};
|
|
@@ -111,7 +126,8 @@ export class TUIApp {
|
|
|
111
126
|
|
|
112
127
|
this.hintsText = new TextRenderable(this.renderer, {
|
|
113
128
|
id: "hints",
|
|
114
|
-
content:
|
|
129
|
+
content:
|
|
130
|
+
"↑↓ select →/space enter ← back n new e edit d done x del m mode v move / search q quit",
|
|
115
131
|
fg: colors.keyHints,
|
|
116
132
|
position: "absolute",
|
|
117
133
|
left: 1,
|
|
@@ -318,14 +334,53 @@ export class TUIApp {
|
|
|
318
334
|
"e Edit selected",
|
|
319
335
|
"d Toggle done",
|
|
320
336
|
"x Delete selected",
|
|
337
|
+
"v Move to...",
|
|
338
|
+
"/ or f Search",
|
|
321
339
|
"q Quit",
|
|
322
340
|
].join("\n");
|
|
323
341
|
buttons = "";
|
|
324
342
|
hints = "Press any key to close";
|
|
325
343
|
break;
|
|
344
|
+
case "move": {
|
|
345
|
+
const targets = modal.moveTargets ?? [];
|
|
346
|
+
const idx = modal.selectedTargetIndex ?? 0;
|
|
347
|
+
const lines = targets.map((t, i) => {
|
|
348
|
+
const prefix = i === idx ? ">" : " ";
|
|
349
|
+
const pathStr = t.pathTitles.length > 0 ? ` (${t.pathTitles.join(" > ")})` : "";
|
|
350
|
+
return `${prefix} ${truncate(t.title, width - 8)}${pathStr}`;
|
|
351
|
+
});
|
|
352
|
+
title = "Move to";
|
|
353
|
+
content = lines.length > 0 ? lines.join("\n") : "No valid targets.";
|
|
354
|
+
hints = "↑↓ select Enter:move Esc:cancel";
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "search": {
|
|
358
|
+
const results = modal.searchResults ?? [];
|
|
359
|
+
const idx = modal.selectedResultIndex ?? 0;
|
|
360
|
+
const queryLine = `Query: ${modal.inputValue ?? ""}`;
|
|
361
|
+
const lines = results.map((r, i) => {
|
|
362
|
+
const prefix = i === idx ? ">" : " ";
|
|
363
|
+
const pathStr = r.pathTitles.length > 0 ? ` (${r.pathTitles.join(" > ")})` : "";
|
|
364
|
+
return `${prefix} ${truncate(r.title, width - 8)}${pathStr}`;
|
|
365
|
+
});
|
|
366
|
+
title = "Search";
|
|
367
|
+
content = [queryLine, "", ...lines].join("\n");
|
|
368
|
+
if (results.length === 0 && (modal.inputValue?.trim().length ?? 0) > 0) {
|
|
369
|
+
content += "\nNo matches.";
|
|
370
|
+
}
|
|
371
|
+
hints = "Type to search ↑↓ select Enter:go Esc:cancel";
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
326
374
|
}
|
|
327
375
|
|
|
328
|
-
const modalHeight =
|
|
376
|
+
const modalHeight =
|
|
377
|
+
modal.type === "help"
|
|
378
|
+
? 15
|
|
379
|
+
: modal.type === "move"
|
|
380
|
+
? Math.min(14, (modal.moveTargets?.length ?? 0) + 6)
|
|
381
|
+
: modal.type === "search"
|
|
382
|
+
? Math.min(16, (modal.searchResults?.length ?? 0) + 8)
|
|
383
|
+
: 10;
|
|
329
384
|
const left = Math.floor(((process.stdout.columns || 80) - width) / 2);
|
|
330
385
|
const top = Math.floor(((process.stdout.rows || 24) - modalHeight) / 2);
|
|
331
386
|
|
|
@@ -363,6 +418,17 @@ export class TUIApp {
|
|
|
363
418
|
}
|
|
364
419
|
|
|
365
420
|
private handleNavigationKeyPress(key: KeyEvent): void {
|
|
421
|
+
if (key.meta && key.name?.toLowerCase() === "c") {
|
|
422
|
+
const selected = getSelectedNode(this.state);
|
|
423
|
+
const text = selected?.title ?? "";
|
|
424
|
+
if (text) {
|
|
425
|
+
writeToClipboard(text).then((ok) => {
|
|
426
|
+
this.showFeedback(ok ? "Copied" : "Copy failed");
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
366
432
|
const keyName = key.name?.toLowerCase() || key.sequence;
|
|
367
433
|
|
|
368
434
|
switch (keyName) {
|
|
@@ -420,12 +486,24 @@ export class TUIApp {
|
|
|
420
486
|
case "m":
|
|
421
487
|
this.toggleViewMode();
|
|
422
488
|
break;
|
|
489
|
+
|
|
490
|
+
case "v":
|
|
491
|
+
this.openMoveModal();
|
|
492
|
+
break;
|
|
493
|
+
|
|
494
|
+
case "/":
|
|
495
|
+
case "f":
|
|
496
|
+
this.openSearchModal();
|
|
497
|
+
break;
|
|
423
498
|
}
|
|
424
499
|
}
|
|
425
500
|
|
|
426
501
|
private toggleViewMode(): void {
|
|
427
502
|
const currentMode: ViewMode = this.state.viewMode;
|
|
428
503
|
this.state.viewMode = currentMode === "zen" ? "list" : "zen";
|
|
504
|
+
saveConfig({ viewMode: this.state.viewMode }).catch(() => {
|
|
505
|
+
// Ignore config write failure
|
|
506
|
+
});
|
|
429
507
|
this.showFeedback(this.state.viewMode === "zen" ? "Zen Mode" : "List Mode");
|
|
430
508
|
}
|
|
431
509
|
|
|
@@ -469,11 +547,67 @@ export class TUIApp {
|
|
|
469
547
|
|
|
470
548
|
this.state.modalState = {
|
|
471
549
|
type: "delete",
|
|
472
|
-
selectedButton:
|
|
550
|
+
selectedButton: 0,
|
|
551
|
+
};
|
|
552
|
+
this.updateDisplay();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private openMoveModal(): void {
|
|
556
|
+
const selected = getSelectedNode(this.state);
|
|
557
|
+
if (!selected) {
|
|
558
|
+
this.showFeedback("Nothing selected");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const raw = getValidMoveTargets(this.state.project.root, selected.id);
|
|
563
|
+
const moveTargets: MoveTarget[] = raw.map(({ node, path }) => ({
|
|
564
|
+
nodeId: node.id,
|
|
565
|
+
title: node.title,
|
|
566
|
+
pathTitles: path.slice(1).map((p) => p.title),
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
if (moveTargets.length === 0) {
|
|
570
|
+
this.showFeedback("No valid targets");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.state.modalState = {
|
|
575
|
+
type: "move",
|
|
576
|
+
moveTargets,
|
|
577
|
+
selectedTargetIndex: 0,
|
|
578
|
+
nodeIdToMove: selected.id,
|
|
473
579
|
};
|
|
474
580
|
this.updateDisplay();
|
|
475
581
|
}
|
|
476
582
|
|
|
583
|
+
private openSearchModal(): void {
|
|
584
|
+
this.state.modalState = {
|
|
585
|
+
type: "search",
|
|
586
|
+
inputValue: "",
|
|
587
|
+
searchResults: [],
|
|
588
|
+
selectedResultIndex: 0,
|
|
589
|
+
};
|
|
590
|
+
this.updateDisplay();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private runSearchAndUpdateResults(): void {
|
|
594
|
+
const modal = this.state.modalState;
|
|
595
|
+
if (!modal || modal.type !== "search") return;
|
|
596
|
+
|
|
597
|
+
const query = modal.inputValue ?? "";
|
|
598
|
+
const raw = searchNodes(this.state.project.root, query);
|
|
599
|
+
const searchResults: SearchResult[] = raw.map(({ node, path }) => ({
|
|
600
|
+
nodeId: node.id,
|
|
601
|
+
title: node.title,
|
|
602
|
+
pathTitles: path.slice(1).map((p) => p.title),
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
modal.searchResults = searchResults;
|
|
606
|
+
const idx = modal.selectedResultIndex ?? 0;
|
|
607
|
+
modal.selectedResultIndex =
|
|
608
|
+
searchResults.length === 0 ? 0 : Math.min(idx, searchResults.length - 1);
|
|
609
|
+
}
|
|
610
|
+
|
|
477
611
|
private closeModal(): void {
|
|
478
612
|
this.state.modalState = null;
|
|
479
613
|
this.updateDisplay();
|
|
@@ -490,6 +624,84 @@ export class TUIApp {
|
|
|
490
624
|
return;
|
|
491
625
|
}
|
|
492
626
|
|
|
627
|
+
if (modal.type === "move") {
|
|
628
|
+
const targets = modal.moveTargets ?? [];
|
|
629
|
+
const len = targets.length;
|
|
630
|
+
if (len === 0) {
|
|
631
|
+
if (keyName === "escape") this.closeModal();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let idx = modal.selectedTargetIndex ?? 0;
|
|
635
|
+
switch (keyName) {
|
|
636
|
+
case "escape":
|
|
637
|
+
this.closeModal();
|
|
638
|
+
return;
|
|
639
|
+
case "up":
|
|
640
|
+
case "k":
|
|
641
|
+
idx = idx <= 0 ? 0 : idx - 1;
|
|
642
|
+
modal.selectedTargetIndex = idx;
|
|
643
|
+
this.updateDisplay();
|
|
644
|
+
return;
|
|
645
|
+
case "down":
|
|
646
|
+
case "j":
|
|
647
|
+
idx = idx >= len - 1 ? len - 1 : idx + 1;
|
|
648
|
+
modal.selectedTargetIndex = idx;
|
|
649
|
+
this.updateDisplay();
|
|
650
|
+
return;
|
|
651
|
+
case "return":
|
|
652
|
+
case "enter":
|
|
653
|
+
this.submitMove();
|
|
654
|
+
return;
|
|
655
|
+
default:
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (modal.type === "search") {
|
|
661
|
+
const results = modal.searchResults ?? [];
|
|
662
|
+
const len = results.length;
|
|
663
|
+
let idx = modal.selectedResultIndex ?? 0;
|
|
664
|
+
|
|
665
|
+
switch (keyName) {
|
|
666
|
+
case "escape":
|
|
667
|
+
this.closeModal();
|
|
668
|
+
return;
|
|
669
|
+
case "up":
|
|
670
|
+
case "k":
|
|
671
|
+
idx = idx <= 0 ? 0 : idx - 1;
|
|
672
|
+
modal.selectedResultIndex = idx;
|
|
673
|
+
this.updateDisplay();
|
|
674
|
+
return;
|
|
675
|
+
case "down":
|
|
676
|
+
case "j":
|
|
677
|
+
idx = idx >= len - 1 ? len - 1 : idx + 1;
|
|
678
|
+
modal.selectedResultIndex = idx;
|
|
679
|
+
this.updateDisplay();
|
|
680
|
+
return;
|
|
681
|
+
case "return":
|
|
682
|
+
case "enter":
|
|
683
|
+
if (len > 0 && results[idx]) {
|
|
684
|
+
navigateToNode(this.state, results[idx].nodeId);
|
|
685
|
+
this.closeModal();
|
|
686
|
+
this.updateDisplay();
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
case "backspace":
|
|
690
|
+
case "delete":
|
|
691
|
+
modal.inputValue = (modal.inputValue ?? "").slice(0, -1);
|
|
692
|
+
this.runSearchAndUpdateResults();
|
|
693
|
+
this.updateDisplay();
|
|
694
|
+
return;
|
|
695
|
+
default:
|
|
696
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence.charCodeAt(0) >= 32) {
|
|
697
|
+
modal.inputValue = (modal.inputValue ?? "") + key.sequence;
|
|
698
|
+
this.runSearchAndUpdateResults();
|
|
699
|
+
this.updateDisplay();
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
493
705
|
switch (keyName) {
|
|
494
706
|
case "escape":
|
|
495
707
|
this.closeModal();
|
|
@@ -630,6 +842,33 @@ export class TUIApp {
|
|
|
630
842
|
this.showFeedback("Deleted");
|
|
631
843
|
}
|
|
632
844
|
|
|
845
|
+
private async submitMove(): Promise<void> {
|
|
846
|
+
const modal = this.state.modalState;
|
|
847
|
+
if (!modal || modal.type !== "move" || !modal.nodeIdToMove) {
|
|
848
|
+
this.closeModal();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const targets = modal.moveTargets ?? [];
|
|
853
|
+
const idx = modal.selectedTargetIndex ?? 0;
|
|
854
|
+
const target = targets[idx];
|
|
855
|
+
if (!target) {
|
|
856
|
+
this.closeModal();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const ok = moveNode(this.state.project.root, modal.nodeIdToMove, target.nodeId);
|
|
861
|
+
if (!ok) {
|
|
862
|
+
this.showFeedback("Move failed");
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
navigateToNode(this.state, modal.nodeIdToMove);
|
|
867
|
+
await this.save();
|
|
868
|
+
this.closeModal();
|
|
869
|
+
this.showFeedback("Moved");
|
|
870
|
+
}
|
|
871
|
+
|
|
633
872
|
private async toggleDone(): Promise<void> {
|
|
634
873
|
const selected = getSelectedNode(this.state);
|
|
635
874
|
if (!selected) {
|
|
@@ -723,6 +962,8 @@ function disableMouseTracking(): void {
|
|
|
723
962
|
export async function startTUI(project: Project): Promise<void> {
|
|
724
963
|
disableMouseTracking();
|
|
725
964
|
|
|
965
|
+
const config = await loadConfig();
|
|
966
|
+
|
|
726
967
|
const renderer = await createCliRenderer({
|
|
727
968
|
exitOnCtrlC: true,
|
|
728
969
|
});
|
|
@@ -745,7 +986,7 @@ export async function startTUI(project: Project): Promise<void> {
|
|
|
745
986
|
restoreTerminal();
|
|
746
987
|
});
|
|
747
988
|
|
|
748
|
-
const app = new TUIApp(renderer, project);
|
|
989
|
+
const app = new TUIApp(renderer, project, config.viewMode);
|
|
749
990
|
|
|
750
991
|
try {
|
|
751
992
|
await app.start();
|
package/src/tui/navigation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { findNode } from "../data/operations";
|
|
1
|
+
import { findNode, getNodePath } from "../data/operations";
|
|
2
2
|
import type { AppState, Node } from "../data/types";
|
|
3
3
|
|
|
4
4
|
export interface NavigationResult {
|
|
@@ -7,26 +7,30 @@ export interface NavigationResult {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function getCurrentList(state: AppState): Node[] {
|
|
10
|
+
let children: Node[];
|
|
10
11
|
if (state.navigationStack.length === 0) {
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
children = state.project.root.children;
|
|
13
|
+
} else {
|
|
14
|
+
const parentId = state.navigationStack[state.navigationStack.length - 1];
|
|
15
|
+
const parent = findNode(state.project.root, parentId);
|
|
16
|
+
if (!parent) {
|
|
17
|
+
children = state.project.root.children;
|
|
18
|
+
} else {
|
|
19
|
+
children = parent.children;
|
|
20
|
+
}
|
|
19
21
|
}
|
|
20
|
-
|
|
21
|
-
return parent.children;
|
|
22
|
+
return [...children].sort((a, b) => b.children.length - a.children.length);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function getSelectedNode(state: AppState): Node | null {
|
|
25
26
|
const list = getCurrentList(state);
|
|
26
|
-
if (list.length === 0
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if (list.length === 0) return null;
|
|
28
|
+
const index =
|
|
29
|
+
state.selectedNodeId != null
|
|
30
|
+
? list.findIndex((n) => n.id === state.selectedNodeId)
|
|
31
|
+
: state.selectedIndex;
|
|
32
|
+
const i = index >= 0 ? index : 0;
|
|
33
|
+
return list[i] ?? null;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function getCurrentParent(state: AppState): Node | null {
|
|
@@ -58,11 +62,18 @@ export function moveUp(state: AppState): NavigationResult {
|
|
|
58
62
|
return { success: false, feedbackMessage: "List is empty" };
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
const index =
|
|
66
|
+
state.selectedNodeId != null
|
|
67
|
+
? list.findIndex((n) => n.id === state.selectedNodeId)
|
|
68
|
+
: state.selectedIndex;
|
|
69
|
+
const currentIndex = index >= 0 ? index : 0;
|
|
70
|
+
|
|
71
|
+
if (currentIndex <= 0) {
|
|
62
72
|
return { success: false, feedbackMessage: "At top" };
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
state.selectedIndex
|
|
75
|
+
state.selectedIndex = currentIndex - 1;
|
|
76
|
+
state.selectedNodeId = list[state.selectedIndex]?.id ?? null;
|
|
66
77
|
return { success: true };
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -73,11 +84,18 @@ export function moveDown(state: AppState): NavigationResult {
|
|
|
73
84
|
return { success: false, feedbackMessage: "List is empty" };
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
|
|
87
|
+
const index =
|
|
88
|
+
state.selectedNodeId != null
|
|
89
|
+
? list.findIndex((n) => n.id === state.selectedNodeId)
|
|
90
|
+
: state.selectedIndex;
|
|
91
|
+
const currentIndex = index >= 0 ? index : 0;
|
|
92
|
+
|
|
93
|
+
if (currentIndex >= list.length - 1) {
|
|
77
94
|
return { success: false, feedbackMessage: "At bottom" };
|
|
78
95
|
}
|
|
79
96
|
|
|
80
|
-
state.selectedIndex
|
|
97
|
+
state.selectedIndex = currentIndex + 1;
|
|
98
|
+
state.selectedNodeId = list[state.selectedIndex]?.id ?? null;
|
|
81
99
|
return { success: true };
|
|
82
100
|
}
|
|
83
101
|
|
|
@@ -89,7 +107,9 @@ export function diveIn(state: AppState): NavigationResult {
|
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
state.navigationStack.push(selected.id);
|
|
110
|
+
const list = getCurrentList(state);
|
|
92
111
|
state.selectedIndex = 0;
|
|
112
|
+
state.selectedNodeId = list[0]?.id ?? null;
|
|
93
113
|
return { success: true };
|
|
94
114
|
}
|
|
95
115
|
|
|
@@ -107,12 +127,15 @@ export function goBack(state: AppState): NavigationResult {
|
|
|
107
127
|
const previousIndex = list.findIndex((node) => node.id === lastId);
|
|
108
128
|
|
|
109
129
|
state.selectedIndex = previousIndex >= 0 ? previousIndex : 0;
|
|
130
|
+
state.selectedNodeId = list[state.selectedIndex]?.id ?? lastId;
|
|
110
131
|
return { success: true };
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
export function initializeNavigation(state: AppState): void {
|
|
114
135
|
state.navigationStack = [];
|
|
136
|
+
const list = getCurrentList(state);
|
|
115
137
|
state.selectedIndex = 0;
|
|
138
|
+
state.selectedNodeId = list[0]?.id ?? null;
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
export function adjustSelectionAfterDelete(state: AppState, deletedIndex: number): void {
|
|
@@ -120,16 +143,26 @@ export function adjustSelectionAfterDelete(state: AppState, deletedIndex: number
|
|
|
120
143
|
|
|
121
144
|
if (list.length === 0) {
|
|
122
145
|
state.selectedIndex = 0;
|
|
146
|
+
state.selectedNodeId = null;
|
|
123
147
|
return;
|
|
124
148
|
}
|
|
125
149
|
|
|
126
|
-
|
|
127
|
-
state.
|
|
150
|
+
const index =
|
|
151
|
+
state.selectedNodeId != null
|
|
152
|
+
? list.findIndex((n) => n.id === state.selectedNodeId)
|
|
153
|
+
: state.selectedIndex;
|
|
154
|
+
let newIndex = index >= 0 ? index : 0;
|
|
155
|
+
|
|
156
|
+
if (deletedIndex <= newIndex && newIndex > 0) {
|
|
157
|
+
newIndex--;
|
|
128
158
|
}
|
|
129
159
|
|
|
130
|
-
if (
|
|
131
|
-
|
|
160
|
+
if (newIndex >= list.length) {
|
|
161
|
+
newIndex = list.length - 1;
|
|
132
162
|
}
|
|
163
|
+
|
|
164
|
+
state.selectedIndex = newIndex;
|
|
165
|
+
state.selectedNodeId = list[newIndex]?.id ?? null;
|
|
133
166
|
}
|
|
134
167
|
|
|
135
168
|
export function ensureValidSelection(state: AppState): void {
|
|
@@ -137,14 +170,43 @@ export function ensureValidSelection(state: AppState): void {
|
|
|
137
170
|
|
|
138
171
|
if (list.length === 0) {
|
|
139
172
|
state.selectedIndex = 0;
|
|
173
|
+
state.selectedNodeId = null;
|
|
140
174
|
return;
|
|
141
175
|
}
|
|
142
176
|
|
|
143
|
-
|
|
144
|
-
|
|
177
|
+
let index: number;
|
|
178
|
+
if (state.selectedNodeId != null) {
|
|
179
|
+
index = list.findIndex((n) => n.id === state.selectedNodeId);
|
|
180
|
+
if (index < 0) index = 0;
|
|
181
|
+
} else {
|
|
182
|
+
index = state.selectedIndex;
|
|
145
183
|
}
|
|
146
184
|
|
|
147
|
-
if (
|
|
148
|
-
|
|
185
|
+
if (index < 0) index = 0;
|
|
186
|
+
if (index >= list.length) index = list.length - 1;
|
|
187
|
+
|
|
188
|
+
state.selectedIndex = index;
|
|
189
|
+
state.selectedNodeId = list[index]?.id ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function navigateToNode(state: AppState, nodeId: string): void {
|
|
193
|
+
const path = getNodePath(state.project.root, nodeId);
|
|
194
|
+
if (!path || path.length === 0) return;
|
|
195
|
+
|
|
196
|
+
if (path.length === 1) {
|
|
197
|
+
state.navigationStack = [];
|
|
198
|
+
state.selectedIndex = 0;
|
|
199
|
+
state.selectedNodeId = path[0]?.id ?? null;
|
|
200
|
+
return;
|
|
149
201
|
}
|
|
202
|
+
|
|
203
|
+
const parent = path[path.length - 2];
|
|
204
|
+
const node = path[path.length - 1];
|
|
205
|
+
if (!parent || !node) return;
|
|
206
|
+
|
|
207
|
+
state.navigationStack = path.slice(1, -1).map((n) => n.id);
|
|
208
|
+
const list = getCurrentList(state);
|
|
209
|
+
const index = list.findIndex((n) => n.id === node.id);
|
|
210
|
+
state.selectedIndex = index >= 0 ? index : 0;
|
|
211
|
+
state.selectedNodeId = node.id;
|
|
150
212
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { platform } from "node:os";
|
|
2
|
+
|
|
3
|
+
export async function writeToClipboard(text: string): Promise<boolean> {
|
|
4
|
+
const plat = platform();
|
|
5
|
+
let cmd: string;
|
|
6
|
+
let args: string[];
|
|
7
|
+
|
|
8
|
+
if (plat === "darwin") {
|
|
9
|
+
cmd = "pbcopy";
|
|
10
|
+
args = [];
|
|
11
|
+
} else if (plat === "win32") {
|
|
12
|
+
cmd = "clip";
|
|
13
|
+
args = [];
|
|
14
|
+
} else {
|
|
15
|
+
// Linux: try xclip first, then xsel
|
|
16
|
+
cmd = "xclip";
|
|
17
|
+
args = ["-selection", "clipboard"];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
22
|
+
stdin: "pipe",
|
|
23
|
+
stdout: "ignore",
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
});
|
|
26
|
+
proc.stdin.write(text);
|
|
27
|
+
proc.stdin.end();
|
|
28
|
+
const exit = await proc.exited;
|
|
29
|
+
if (exit === 0) return true;
|
|
30
|
+
} catch {
|
|
31
|
+
// pbcopy/clip/xclip failed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (plat !== "darwin" && plat !== "win32") {
|
|
35
|
+
try {
|
|
36
|
+
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
|
37
|
+
stdin: "pipe",
|
|
38
|
+
stdout: "ignore",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
proc.stdin.write(text);
|
|
42
|
+
proc.stdin.end();
|
|
43
|
+
const exit = await proc.exited;
|
|
44
|
+
if (exit === 0) return true;
|
|
45
|
+
} catch {
|
|
46
|
+
// xsel failed
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
}
|
package/src/utils/platform.ts
CHANGED
|
@@ -12,6 +12,10 @@ export function getDataDir(): string {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function getConfigPath(): string {
|
|
16
|
+
return join(getDataDir(), "depthfirst", "config.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
export function getProjectsDir(): string {
|
|
16
20
|
return join(getDataDir(), "depthfirst", "projects");
|
|
17
21
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "2.1.
|
|
1
|
+
export const VERSION = "2.1.9";
|