depth-first-thinking 2.0.7 → 2.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.
@@ -0,0 +1,143 @@
1
+ import { findNode } from "../data/operations";
2
+ import type { AppState, Node } from "../data/types";
3
+
4
+ export interface NavigationResult {
5
+ success: boolean;
6
+ feedbackMessage?: string;
7
+ }
8
+
9
+ export function getCurrentList(state: AppState): Node[] {
10
+ if (state.navigationStack.length === 0) {
11
+ return state.project.root.children;
12
+ }
13
+
14
+ const parentId = state.navigationStack[state.navigationStack.length - 1];
15
+ const parent = findNode(state.project.root, parentId);
16
+
17
+ if (!parent) {
18
+ return state.project.root.children;
19
+ }
20
+
21
+ return parent.children;
22
+ }
23
+
24
+ export function getSelectedNode(state: AppState): Node | null {
25
+ const list = getCurrentList(state);
26
+ if (list.length === 0 || state.selectedIndex >= list.length) {
27
+ return null;
28
+ }
29
+ return list[state.selectedIndex] || null;
30
+ }
31
+
32
+ export function getCurrentParent(state: AppState): Node | null {
33
+ if (state.navigationStack.length === 0) {
34
+ return null;
35
+ }
36
+
37
+ const parentId = state.navigationStack[state.navigationStack.length - 1];
38
+ return findNode(state.project.root, parentId);
39
+ }
40
+
41
+ export function getBreadcrumbPath(state: AppState): Node[] {
42
+ const path: Node[] = [];
43
+
44
+ for (const nodeId of state.navigationStack) {
45
+ const node = findNode(state.project.root, nodeId);
46
+ if (node) {
47
+ path.push(node);
48
+ }
49
+ }
50
+
51
+ return path;
52
+ }
53
+
54
+ export function moveUp(state: AppState): NavigationResult {
55
+ const list = getCurrentList(state);
56
+
57
+ if (list.length === 0) {
58
+ return { success: false, feedbackMessage: "List is empty" };
59
+ }
60
+
61
+ if (state.selectedIndex <= 0) {
62
+ return { success: false, feedbackMessage: "At top" };
63
+ }
64
+
65
+ state.selectedIndex--;
66
+ return { success: true };
67
+ }
68
+
69
+ export function moveDown(state: AppState): NavigationResult {
70
+ const list = getCurrentList(state);
71
+
72
+ if (list.length === 0) {
73
+ return { success: false, feedbackMessage: "List is empty" };
74
+ }
75
+
76
+ if (state.selectedIndex >= list.length - 1) {
77
+ return { success: false, feedbackMessage: "At bottom" };
78
+ }
79
+
80
+ state.selectedIndex++;
81
+ return { success: true };
82
+ }
83
+
84
+ export function diveIn(state: AppState): NavigationResult {
85
+ const selected = getSelectedNode(state);
86
+
87
+ if (!selected) {
88
+ return { success: false, feedbackMessage: "Nothing selected" };
89
+ }
90
+
91
+ state.navigationStack.push(selected.id);
92
+ state.selectedIndex = 0;
93
+ return { success: true };
94
+ }
95
+
96
+ export function goBack(state: AppState): NavigationResult {
97
+ if (state.navigationStack.length === 0) {
98
+ return { success: false, feedbackMessage: "At root" };
99
+ }
100
+
101
+ state.navigationStack.pop();
102
+ state.selectedIndex = 0;
103
+ return { success: true };
104
+ }
105
+
106
+ export function initializeNavigation(state: AppState): void {
107
+ state.navigationStack = [];
108
+ state.selectedIndex = 0;
109
+ }
110
+
111
+ export function adjustSelectionAfterDelete(state: AppState, deletedIndex: number): void {
112
+ const list = getCurrentList(state);
113
+
114
+ if (list.length === 0) {
115
+ state.selectedIndex = 0;
116
+ return;
117
+ }
118
+
119
+ if (deletedIndex <= state.selectedIndex && state.selectedIndex > 0) {
120
+ state.selectedIndex--;
121
+ }
122
+
123
+ if (state.selectedIndex >= list.length) {
124
+ state.selectedIndex = list.length - 1;
125
+ }
126
+ }
127
+
128
+ export function ensureValidSelection(state: AppState): void {
129
+ const list = getCurrentList(state);
130
+
131
+ if (list.length === 0) {
132
+ state.selectedIndex = 0;
133
+ return;
134
+ }
135
+
136
+ if (state.selectedIndex < 0) {
137
+ state.selectedIndex = 0;
138
+ }
139
+
140
+ if (state.selectedIndex >= list.length) {
141
+ state.selectedIndex = list.length - 1;
142
+ }
143
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatCheckbox, truncate } from "./formatting";
3
+
4
+ describe("truncate", () => {
5
+ test("returns text unchanged if shorter than max", () => {
6
+ expect(truncate("hello", 10)).toBe("hello");
7
+ });
8
+
9
+ test("returns text unchanged if equal to max", () => {
10
+ expect(truncate("hello", 5)).toBe("hello");
11
+ });
12
+
13
+ test("truncates with ellipsis if longer than max", () => {
14
+ expect(truncate("hello world", 8)).toBe("hello...");
15
+ });
16
+
17
+ test("handles very short max length", () => {
18
+ expect(truncate("hello", 4)).toBe("h...");
19
+ });
20
+ });
21
+
22
+ describe("formatCheckbox", () => {
23
+ test("returns [ ] for open status", () => {
24
+ expect(formatCheckbox("open")).toBe("[ ]");
25
+ });
26
+
27
+ test("returns [x] for done status", () => {
28
+ expect(formatCheckbox("done")).toBe("[x]");
29
+ });
30
+ });
@@ -0,0 +1,10 @@
1
+ export function truncate(text: string, maxLength: number): string {
2
+ if (text.length <= maxLength) {
3
+ return text;
4
+ }
5
+ return `${text.substring(0, maxLength - 3)}...`;
6
+ }
7
+
8
+ export function formatCheckbox(status: "open" | "done"): string {
9
+ return status === "done" ? "[x]" : "[ ]";
10
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getDataDir, getProjectPath, getProjectsDir } from "./platform";
3
+
4
+ describe("getDataDir", () => {
5
+ test("returns a non-empty string", () => {
6
+ const dataDir = getDataDir();
7
+ expect(typeof dataDir).toBe("string");
8
+ expect(dataDir.length).toBeGreaterThan(0);
9
+ });
10
+ });
11
+
12
+ describe("getProjectsDir", () => {
13
+ test("includes depthfirst/projects", () => {
14
+ const projectsDir = getProjectsDir();
15
+ expect(projectsDir).toContain("depthfirst");
16
+ expect(projectsDir).toContain("projects");
17
+ });
18
+ });
19
+
20
+ describe("getProjectPath", () => {
21
+ test("returns path with .json extension", () => {
22
+ const path = getProjectPath("my-project");
23
+ expect(path).toEndWith(".json");
24
+ });
25
+
26
+ test("converts name to lowercase", () => {
27
+ const path = getProjectPath("MyProject");
28
+ expect(path).toContain("myproject.json");
29
+ });
30
+ });
@@ -0,0 +1,21 @@
1
+ import { homedir, platform } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function getDataDir(): string {
5
+ switch (platform()) {
6
+ case "darwin":
7
+ return join(homedir(), "Library", "Application Support");
8
+ case "win32":
9
+ return process.env.APPDATA || join(homedir(), "AppData", "Roaming");
10
+ default:
11
+ return process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
12
+ }
13
+ }
14
+
15
+ export function getProjectsDir(): string {
16
+ return join(getDataDir(), "depthfirst", "projects");
17
+ }
18
+
19
+ export function getProjectPath(name: string): string {
20
+ return join(getProjectsDir(), `${name.toLowerCase()}.json`);
21
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isValidProjectName, isValidTitle, validateProjectName, validateTitle } from "./validation";
3
+
4
+ describe("validateProjectName", () => {
5
+ test("accepts valid project names", () => {
6
+ expect(validateProjectName("my-project").isValid).toBe(true);
7
+ expect(validateProjectName("project123").isValid).toBe(true);
8
+ expect(validateProjectName("test_project").isValid).toBe(true);
9
+ expect(validateProjectName("a").isValid).toBe(true);
10
+ });
11
+
12
+ test("rejects empty names", () => {
13
+ const result = validateProjectName("");
14
+ expect(result.isValid).toBe(false);
15
+ expect(result.error).toContain("at least 1 character");
16
+ });
17
+
18
+ test("rejects names longer than 50 characters", () => {
19
+ const longName = "a".repeat(51);
20
+ const result = validateProjectName(longName);
21
+ expect(result.isValid).toBe(false);
22
+ expect(result.error).toContain("50 characters or less");
23
+ });
24
+
25
+ test("rejects names starting with non-alphanumeric", () => {
26
+ const result = validateProjectName("-project");
27
+ expect(result.isValid).toBe(false);
28
+ expect(result.error).toContain("start with a letter or number");
29
+ });
30
+
31
+ test("rejects names with invalid characters", () => {
32
+ const result = validateProjectName("my project");
33
+ expect(result.isValid).toBe(false);
34
+ expect(result.error).toContain("letters, numbers, hyphens, and underscores");
35
+ });
36
+
37
+ test("rejects reserved names", () => {
38
+ const reserved = ["list", "new", "delete", "open", "tree", "help", "version"];
39
+ for (const name of reserved) {
40
+ const result = validateProjectName(name);
41
+ expect(result.isValid).toBe(false);
42
+ expect(result.error).toContain("reserved name");
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("isValidProjectName", () => {
48
+ test("returns true for valid names", () => {
49
+ expect(isValidProjectName("valid-name")).toBe(true);
50
+ });
51
+
52
+ test("returns false for invalid names", () => {
53
+ expect(isValidProjectName("")).toBe(false);
54
+ expect(isValidProjectName("list")).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("validateTitle", () => {
59
+ test("accepts valid titles", () => {
60
+ expect(validateTitle("Build authentication").isValid).toBe(true);
61
+ expect(validateTitle("a").isValid).toBe(true);
62
+ });
63
+
64
+ test("rejects empty titles", () => {
65
+ const result = validateTitle("");
66
+ expect(result.isValid).toBe(false);
67
+ expect(result.error).toContain("empty");
68
+ });
69
+
70
+ test("rejects whitespace-only titles", () => {
71
+ const result = validateTitle(" ");
72
+ expect(result.isValid).toBe(false);
73
+ expect(result.error).toContain("empty");
74
+ });
75
+
76
+ test("rejects titles longer than 200 characters", () => {
77
+ const longTitle = "a".repeat(201);
78
+ const result = validateTitle(longTitle);
79
+ expect(result.isValid).toBe(false);
80
+ expect(result.error).toContain("200 characters or less");
81
+ });
82
+ });
83
+
84
+ describe("isValidTitle", () => {
85
+ test("returns true for valid titles", () => {
86
+ expect(isValidTitle("Valid title")).toBe(true);
87
+ });
88
+
89
+ test("returns false for invalid titles", () => {
90
+ expect(isValidTitle("")).toBe(false);
91
+ expect(isValidTitle(" ")).toBe(false);
92
+ });
93
+ });
@@ -0,0 +1,74 @@
1
+ const RESERVED_NAMES = ["list", "new", "delete", "open", "tree", "help", "version"];
2
+
3
+ export function validateProjectName(name: string): {
4
+ isValid: boolean;
5
+ error?: string;
6
+ } {
7
+ if (name.length < 1) {
8
+ return {
9
+ isValid: false,
10
+ error: "Project name must be at least 1 character long.",
11
+ };
12
+ }
13
+
14
+ if (name.length > 50) {
15
+ return {
16
+ isValid: false,
17
+ error: "Project name must be 50 characters or less.",
18
+ };
19
+ }
20
+
21
+ if (!/^[a-zA-Z0-9]/.test(name)) {
22
+ return {
23
+ isValid: false,
24
+ error: "Project name must start with a letter or number.",
25
+ };
26
+ }
27
+
28
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
29
+ return {
30
+ isValid: false,
31
+ error: "Project name can only contain letters, numbers, hyphens, and underscores.",
32
+ };
33
+ }
34
+
35
+ if (RESERVED_NAMES.includes(name.toLowerCase())) {
36
+ return {
37
+ isValid: false,
38
+ error: `'${name}' is a reserved name. Please choose another.`,
39
+ };
40
+ }
41
+
42
+ return { isValid: true };
43
+ }
44
+
45
+ export function isValidProjectName(name: string): boolean {
46
+ return validateProjectName(name).isValid;
47
+ }
48
+
49
+ export function validateTitle(title: string): {
50
+ isValid: boolean;
51
+ error?: string;
52
+ } {
53
+ const trimmed = title.trim();
54
+
55
+ if (trimmed.length < 1) {
56
+ return {
57
+ isValid: false,
58
+ error: "Title cannot be empty or contain only whitespace.",
59
+ };
60
+ }
61
+
62
+ if (trimmed.length > 200) {
63
+ return {
64
+ isValid: false,
65
+ error: "Title must be 200 characters or less.",
66
+ };
67
+ }
68
+
69
+ return { isValid: true };
70
+ }
71
+
72
+ export function isValidTitle(title: string): boolean {
73
+ return validateTitle(title).isValid;
74
+ }
package/dist/dft DELETED
Binary file