wdyt 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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Select commands - get, add
3
+ *
4
+ * Commands:
5
+ * - select get: returns selected file paths (newline-separated)
6
+ * - select add <paths>: adds files to selection
7
+ *
8
+ * Compatible with flowctl.py:
9
+ * - cmd_rp_select_get (line 3946): select get
10
+ * - cmd_rp_select_add (line 3955): select add <paths>
11
+ */
12
+
13
+ import { existsSync } from "fs";
14
+ import { resolve } from "path";
15
+ import { getTab, updateTab, getWindow } from "../state";
16
+
17
+ /**
18
+ * Select get response
19
+ */
20
+ export interface SelectGetResponse {
21
+ files: string[];
22
+ }
23
+
24
+ /**
25
+ * Get selected files for a tab
26
+ *
27
+ * @param windowId - Window ID
28
+ * @param tabId - Tab ID
29
+ * @returns Newline-separated file paths
30
+ */
31
+ export async function selectGetCommand(
32
+ windowId: number,
33
+ tabId: string
34
+ ): Promise<{
35
+ success: boolean;
36
+ data?: SelectGetResponse;
37
+ output?: string;
38
+ error?: string;
39
+ }> {
40
+ try {
41
+ const tab = await getTab(windowId, tabId);
42
+
43
+ // Return newline-separated paths for non-JSON output
44
+ const output = tab.selectedFiles.join("\n");
45
+
46
+ return {
47
+ success: true,
48
+ data: { files: tab.selectedFiles },
49
+ output,
50
+ };
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ return {
54
+ success: false,
55
+ error: `Failed to get selection: ${message}`,
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Parse shell-quoted paths from the arguments string
62
+ *
63
+ * Handles paths that may be quoted with single quotes (from shlex.quote)
64
+ * Example: "'path/to/file.ts' 'another file.ts'"
65
+ */
66
+ function parsePaths(argsString: string): string[] {
67
+ const paths: string[] = [];
68
+ let remaining = argsString.trim();
69
+
70
+ while (remaining.length > 0) {
71
+ remaining = remaining.trimStart();
72
+
73
+ if (remaining.startsWith("'")) {
74
+ // Single-quoted path
75
+ const endQuote = remaining.indexOf("'", 1);
76
+ if (endQuote === -1) {
77
+ // Unterminated quote, take rest
78
+ paths.push(remaining.slice(1));
79
+ break;
80
+ }
81
+ paths.push(remaining.slice(1, endQuote));
82
+ remaining = remaining.slice(endQuote + 1);
83
+ } else if (remaining.startsWith('"')) {
84
+ // Double-quoted path
85
+ const endQuote = remaining.indexOf('"', 1);
86
+ if (endQuote === -1) {
87
+ paths.push(remaining.slice(1));
88
+ break;
89
+ }
90
+ paths.push(remaining.slice(1, endQuote));
91
+ remaining = remaining.slice(endQuote + 1);
92
+ } else {
93
+ // Unquoted path - ends at whitespace
94
+ const spaceIndex = remaining.search(/\s/);
95
+ if (spaceIndex === -1) {
96
+ paths.push(remaining);
97
+ break;
98
+ }
99
+ paths.push(remaining.slice(0, spaceIndex));
100
+ remaining = remaining.slice(spaceIndex);
101
+ }
102
+ }
103
+
104
+ return paths.filter((p) => p.length > 0);
105
+ }
106
+
107
+ /**
108
+ * Add files to selection for a tab
109
+ *
110
+ * @param windowId - Window ID
111
+ * @param tabId - Tab ID
112
+ * @param argsString - Space-separated file paths (may be shell-quoted)
113
+ * @returns Success status with count of added files
114
+ */
115
+ export async function selectAddCommand(
116
+ windowId: number,
117
+ tabId: string,
118
+ argsString: string
119
+ ): Promise<{
120
+ success: boolean;
121
+ data?: { added: number; total: number };
122
+ output?: string;
123
+ error?: string;
124
+ }> {
125
+ try {
126
+ // Parse the paths from the args string
127
+ const pathsToAdd = parsePaths(argsString);
128
+
129
+ if (pathsToAdd.length === 0) {
130
+ return {
131
+ success: false,
132
+ error: "select add requires at least one path",
133
+ };
134
+ }
135
+
136
+ // Get window to determine root paths for resolving relative paths
137
+ const window = await getWindow(windowId);
138
+ const rootPath = window.rootFolderPaths[0] || process.cwd();
139
+
140
+ // Get current selection
141
+ const tab = await getTab(windowId, tabId);
142
+ const existingFiles = new Set(tab.selectedFiles);
143
+
144
+ // Process each path
145
+ let addedCount = 0;
146
+ for (const path of pathsToAdd) {
147
+ // Resolve to absolute path if relative
148
+ const absolutePath = resolve(rootPath, path);
149
+
150
+ // Skip if already selected (deduplicate)
151
+ if (existingFiles.has(absolutePath)) {
152
+ continue;
153
+ }
154
+
155
+ // Check if file exists - silently skip non-existent files
156
+ if (!existsSync(absolutePath)) {
157
+ continue;
158
+ }
159
+
160
+ existingFiles.add(absolutePath);
161
+ addedCount++;
162
+ }
163
+
164
+ // Update the tab with new selection
165
+ const newSelection = Array.from(existingFiles);
166
+ await updateTab(windowId, tabId, { selectedFiles: newSelection });
167
+
168
+ return {
169
+ success: true,
170
+ data: { added: addedCount, total: newSelection.length },
171
+ output: `Added ${addedCount} file(s), total: ${newSelection.length}`,
172
+ };
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ return {
176
+ success: false,
177
+ error: `Failed to add to selection: ${message}`,
178
+ };
179
+ }
180
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Windows command - list all windows
3
+ *
4
+ * Returns JSON: {windows: [{windowID, rootFolderPaths}]}
5
+ * Compatible with flowctl.py parsing at line 215-231
6
+ */
7
+
8
+ import { getWindows } from "../state";
9
+
10
+ /**
11
+ * Window data formatted for flowctl.py compatibility
12
+ * Uses windowID (not id) to match expected format
13
+ */
14
+ export interface WindowOutput {
15
+ windowID: number;
16
+ rootFolderPaths: string[];
17
+ }
18
+
19
+ /**
20
+ * Windows command response
21
+ */
22
+ export interface WindowsResponse {
23
+ windows: WindowOutput[];
24
+ }
25
+
26
+ /**
27
+ * Execute the windows command
28
+ * Returns all windows with their IDs and root folder paths
29
+ */
30
+ export async function windowsCommand(): Promise<{
31
+ success: boolean;
32
+ data?: WindowsResponse;
33
+ error?: string;
34
+ }> {
35
+ try {
36
+ const windows = await getWindows();
37
+
38
+ return {
39
+ success: true,
40
+ data: {
41
+ windows: windows.map((w) => ({
42
+ windowID: w.id,
43
+ rootFolderPaths: w.rootFolderPaths,
44
+ })),
45
+ },
46
+ };
47
+ } catch (error) {
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ return {
50
+ success: false,
51
+ error: `Failed to get windows: ${message}`,
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Tests for state management layer
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
+ import { join } from "path";
7
+ import { rm } from "fs/promises";
8
+ import {
9
+ loadState,
10
+ saveState,
11
+ getWindow,
12
+ getWindows,
13
+ createTab,
14
+ getTab,
15
+ updateTab,
16
+ deleteTab,
17
+ createWindow,
18
+ ensureState,
19
+ } from "./state";
20
+
21
+ // Use a test-specific directory
22
+ const TEST_DIR = join(import.meta.dir, "..", ".test-state");
23
+
24
+ beforeEach(async () => {
25
+ // Set XDG_DATA_HOME to test directory
26
+ process.env.XDG_DATA_HOME = TEST_DIR;
27
+
28
+ // Clean up any existing test state
29
+ try {
30
+ await rm(TEST_DIR, { recursive: true, force: true });
31
+ } catch {
32
+ // Directory might not exist
33
+ }
34
+ });
35
+
36
+ afterEach(async () => {
37
+ // Clean up test directory
38
+ try {
39
+ await rm(TEST_DIR, { recursive: true, force: true });
40
+ } catch {
41
+ // Directory might not exist
42
+ }
43
+ delete process.env.XDG_DATA_HOME;
44
+ });
45
+
46
+ describe("State management", () => {
47
+ test("loadState creates default state if none exists", async () => {
48
+ const state = await loadState();
49
+
50
+ expect(state).toBeDefined();
51
+ expect(state.version).toBe(1);
52
+ expect(state.windows).toHaveLength(1);
53
+ expect(state.windows[0].id).toBe(1);
54
+ expect(state.windows[0].tabs).toHaveLength(0);
55
+ expect(state.windows[0].rootFolderPaths).toEqual([]);
56
+ });
57
+
58
+ test("state survives process restarts (save and reload)", async () => {
59
+ // Initial load creates default
60
+ await ensureState();
61
+
62
+ // Create a tab
63
+ const tab = await createTab(1);
64
+ expect(tab.id).toBeDefined();
65
+ expect(tab.prompt).toBe("");
66
+ expect(tab.selectedFiles).toEqual([]);
67
+
68
+ // Update the tab
69
+ await updateTab(1, tab.id, {
70
+ prompt: "test prompt",
71
+ selectedFiles: ["file1.ts", "file2.ts"],
72
+ });
73
+
74
+ // Reload state (simulating process restart)
75
+ const reloadedState = await loadState();
76
+ const reloadedWindow = reloadedState.windows.find((w) => w.id === 1);
77
+ expect(reloadedWindow).toBeDefined();
78
+ expect(reloadedWindow!.tabs).toHaveLength(1);
79
+
80
+ const reloadedTab = reloadedWindow!.tabs[0];
81
+ expect(reloadedTab.id).toBe(tab.id);
82
+ expect(reloadedTab.prompt).toBe("test prompt");
83
+ expect(reloadedTab.selectedFiles).toEqual(["file1.ts", "file2.ts"]);
84
+ });
85
+
86
+ test("getWindow returns window or throws", async () => {
87
+ await ensureState();
88
+
89
+ // Window 1 should exist by default
90
+ const window = await getWindow(1);
91
+ expect(window.id).toBe(1);
92
+
93
+ // Window 999 should not exist
94
+ await expect(getWindow(999)).rejects.toThrow("Window 999 not found");
95
+ });
96
+
97
+ test("createTab returns new tab with UUID", async () => {
98
+ await ensureState();
99
+
100
+ const tab = await createTab(1);
101
+
102
+ expect(tab.id).toBeDefined();
103
+ expect(tab.id.length).toBe(36); // UUID length
104
+ expect(tab.prompt).toBe("");
105
+ expect(tab.selectedFiles).toEqual([]);
106
+ expect(tab.createdAt).toBeDefined();
107
+
108
+ // Verify it was persisted
109
+ const window = await getWindow(1);
110
+ expect(window.tabs).toHaveLength(1);
111
+ expect(window.tabs[0].id).toBe(tab.id);
112
+ });
113
+
114
+ test("updateTab persists changes", async () => {
115
+ await ensureState();
116
+
117
+ const tab = await createTab(1);
118
+
119
+ // Update prompt only
120
+ const updated1 = await updateTab(1, tab.id, { prompt: "new prompt" });
121
+ expect(updated1.prompt).toBe("new prompt");
122
+ expect(updated1.selectedFiles).toEqual([]);
123
+
124
+ // Update selectedFiles only
125
+ const updated2 = await updateTab(1, tab.id, {
126
+ selectedFiles: ["a.ts", "b.ts"],
127
+ });
128
+ expect(updated2.prompt).toBe("new prompt");
129
+ expect(updated2.selectedFiles).toEqual(["a.ts", "b.ts"]);
130
+
131
+ // Verify persistence
132
+ const reloadedTab = await getTab(1, tab.id);
133
+ expect(reloadedTab.prompt).toBe("new prompt");
134
+ expect(reloadedTab.selectedFiles).toEqual(["a.ts", "b.ts"]);
135
+ });
136
+
137
+ test("createTab throws for non-existent window", async () => {
138
+ await ensureState();
139
+
140
+ await expect(createTab(999)).rejects.toThrow("Window 999 not found");
141
+ });
142
+
143
+ test("updateTab throws for non-existent tab", async () => {
144
+ await ensureState();
145
+
146
+ await expect(updateTab(1, "fake-id", { prompt: "test" })).rejects.toThrow(
147
+ "Tab fake-id not found"
148
+ );
149
+ });
150
+
151
+ test("deleteTab removes tab from window", async () => {
152
+ await ensureState();
153
+
154
+ const tab = await createTab(1);
155
+ const window1 = await getWindow(1);
156
+ expect(window1.tabs).toHaveLength(1);
157
+
158
+ await deleteTab(1, tab.id);
159
+
160
+ const window2 = await getWindow(1);
161
+ expect(window2.tabs).toHaveLength(0);
162
+ });
163
+
164
+ test("createWindow adds new window with incremented ID", async () => {
165
+ await ensureState();
166
+
167
+ const newWindow = await createWindow(["/path/to/project"]);
168
+
169
+ expect(newWindow.id).toBe(2);
170
+ expect(newWindow.rootFolderPaths).toEqual(["/path/to/project"]);
171
+ expect(newWindow.tabs).toHaveLength(0);
172
+
173
+ const windows = await getWindows();
174
+ expect(windows).toHaveLength(2);
175
+ });
176
+
177
+ test("getWindows returns all windows", async () => {
178
+ await ensureState();
179
+
180
+ const windows = await getWindows();
181
+ expect(windows).toHaveLength(1);
182
+ expect(windows[0].id).toBe(1);
183
+ });
184
+ });
package/src/state.ts ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * State management layer for wdyt
3
+ *
4
+ * Persists windows, tabs, and selections to disk.
5
+ * State is stored in ~/.wdyt/state.json (or XDG_DATA_HOME/wdyt/state.json)
6
+ */
7
+
8
+ import { join } from "path";
9
+ import { homedir } from "os";
10
+ import { mkdirSync, existsSync, renameSync } from "fs";
11
+ import type { StateFile, Window, Tab, TabUpdate } from "./types";
12
+
13
+ const STATE_VERSION = 1;
14
+
15
+ /**
16
+ * Get the data directory path
17
+ * Uses XDG_DATA_HOME if set, otherwise falls back to ~/.wdyt
18
+ */
19
+ function getDataDir(): string {
20
+ const xdgDataHome = process.env.XDG_DATA_HOME;
21
+ if (xdgDataHome) {
22
+ return join(xdgDataHome, "wdyt");
23
+ }
24
+ return join(homedir(), ".wdyt");
25
+ }
26
+
27
+ /**
28
+ * Get the state file path
29
+ */
30
+ function getStatePath(): string {
31
+ return join(getDataDir(), "state.json");
32
+ }
33
+
34
+ /**
35
+ * Create default state with a single window
36
+ */
37
+ function createDefaultState(): StateFile {
38
+ return {
39
+ version: STATE_VERSION,
40
+ windows: [
41
+ {
42
+ id: 1,
43
+ rootFolderPaths: [],
44
+ tabs: [],
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Generate a UUID v4 for tab IDs
52
+ */
53
+ function generateUUID(): string {
54
+ return crypto.randomUUID();
55
+ }
56
+
57
+ /**
58
+ * Load state from disk, creating default if not exists
59
+ */
60
+ export async function loadState(): Promise<StateFile> {
61
+ const statePath = getStatePath();
62
+
63
+ // Check if file exists first
64
+ if (!existsSync(statePath)) {
65
+ return createDefaultState();
66
+ }
67
+
68
+ try {
69
+ const file = Bun.file(statePath);
70
+ const content = await file.text();
71
+ const state = JSON.parse(content) as StateFile;
72
+
73
+ // Validate basic structure
74
+ if (!state || typeof state !== "object" || !Array.isArray(state.windows)) {
75
+ throw new Error("Invalid state structure");
76
+ }
77
+
78
+ // Validate and migrate if needed
79
+ if (!state.version || state.version < STATE_VERSION) {
80
+ // Future: handle migrations
81
+ state.version = STATE_VERSION;
82
+ }
83
+
84
+ return state;
85
+ } catch (error) {
86
+ // State file exists but is corrupted - backup and start fresh
87
+ const backupPath = `${statePath}.backup.${Date.now()}`;
88
+ try {
89
+ renameSync(statePath, backupPath);
90
+ console.error(`Warning: State file corrupted, backed up to ${backupPath}`);
91
+ } catch {
92
+ console.error(`Warning: Could not backup corrupted state file: ${error}`);
93
+ }
94
+ return createDefaultState();
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Save state to disk
100
+ */
101
+ export async function saveState(state: StateFile): Promise<void> {
102
+ const statePath = getStatePath();
103
+ const dataDir = getDataDir();
104
+
105
+ // Ensure directory exists using proper mkdir
106
+ mkdirSync(dataDir, { recursive: true });
107
+
108
+ // Write state file atomically
109
+ const content = JSON.stringify(state, null, 2);
110
+ await Bun.write(statePath, content);
111
+ }
112
+
113
+ /**
114
+ * Get a window by ID
115
+ * @throws Error if window not found
116
+ */
117
+ export async function getWindow(id: number): Promise<Window> {
118
+ const state = await loadState();
119
+ const window = state.windows.find((w) => w.id === id);
120
+
121
+ if (!window) {
122
+ throw new Error(`Window ${id} not found`);
123
+ }
124
+
125
+ return window;
126
+ }
127
+
128
+ /**
129
+ * Get all windows
130
+ */
131
+ export async function getWindows(): Promise<Window[]> {
132
+ const state = await loadState();
133
+ return state.windows;
134
+ }
135
+
136
+ /**
137
+ * Create a new tab in a window
138
+ * @returns The newly created tab
139
+ */
140
+ export async function createTab(windowId: number): Promise<Tab> {
141
+ const state = await loadState();
142
+ const window = state.windows.find((w) => w.id === windowId);
143
+
144
+ if (!window) {
145
+ throw new Error(`Window ${windowId} not found`);
146
+ }
147
+
148
+ const tab: Tab = {
149
+ id: generateUUID(),
150
+ prompt: "",
151
+ selectedFiles: [],
152
+ createdAt: new Date().toISOString(),
153
+ };
154
+
155
+ window.tabs.push(tab);
156
+ await saveState(state);
157
+
158
+ return tab;
159
+ }
160
+
161
+ /**
162
+ * Get a tab by ID from a window
163
+ * @throws Error if window or tab not found
164
+ */
165
+ export async function getTab(windowId: number, tabId: string): Promise<Tab> {
166
+ const window = await getWindow(windowId);
167
+ const tab = window.tabs.find((t) => t.id === tabId);
168
+
169
+ if (!tab) {
170
+ throw new Error(`Tab ${tabId} not found in window ${windowId}`);
171
+ }
172
+
173
+ return tab;
174
+ }
175
+
176
+ /**
177
+ * Update a tab in a window
178
+ * @returns The updated tab
179
+ */
180
+ export async function updateTab(
181
+ windowId: number,
182
+ tabId: string,
183
+ update: TabUpdate
184
+ ): Promise<Tab> {
185
+ const state = await loadState();
186
+ const window = state.windows.find((w) => w.id === windowId);
187
+
188
+ if (!window) {
189
+ throw new Error(`Window ${windowId} not found`);
190
+ }
191
+
192
+ const tabIndex = window.tabs.findIndex((t) => t.id === tabId);
193
+ if (tabIndex === -1) {
194
+ throw new Error(`Tab ${tabId} not found in window ${windowId}`);
195
+ }
196
+
197
+ // Merge update into existing tab
198
+ const tab = window.tabs[tabIndex];
199
+ if (update.prompt !== undefined) {
200
+ tab.prompt = update.prompt;
201
+ }
202
+ if (update.selectedFiles !== undefined) {
203
+ tab.selectedFiles = update.selectedFiles;
204
+ }
205
+
206
+ await saveState(state);
207
+
208
+ return tab;
209
+ }
210
+
211
+ /**
212
+ * Delete a tab from a window
213
+ */
214
+ export async function deleteTab(windowId: number, tabId: string): Promise<void> {
215
+ const state = await loadState();
216
+ const window = state.windows.find((w) => w.id === windowId);
217
+
218
+ if (!window) {
219
+ throw new Error(`Window ${windowId} not found`);
220
+ }
221
+
222
+ const tabIndex = window.tabs.findIndex((t) => t.id === tabId);
223
+ if (tabIndex === -1) {
224
+ throw new Error(`Tab ${tabId} not found in window ${windowId}`);
225
+ }
226
+
227
+ window.tabs.splice(tabIndex, 1);
228
+ await saveState(state);
229
+ }
230
+
231
+ /**
232
+ * Create a new window
233
+ * @returns The newly created window
234
+ */
235
+ export async function createWindow(
236
+ rootFolderPaths: string[] = []
237
+ ): Promise<Window> {
238
+ const state = await loadState();
239
+
240
+ // Find the next available ID
241
+ const maxId = state.windows.reduce((max, w) => Math.max(max, w.id), 0);
242
+
243
+ const window: Window = {
244
+ id: maxId + 1,
245
+ rootFolderPaths,
246
+ tabs: [],
247
+ };
248
+
249
+ state.windows.push(window);
250
+ await saveState(state);
251
+
252
+ return window;
253
+ }
254
+
255
+ /**
256
+ * Update window root folder paths
257
+ */
258
+ export async function updateWindowPaths(
259
+ windowId: number,
260
+ rootFolderPaths: string[]
261
+ ): Promise<Window> {
262
+ const state = await loadState();
263
+ const window = state.windows.find((w) => w.id === windowId);
264
+
265
+ if (!window) {
266
+ throw new Error(`Window ${windowId} not found`);
267
+ }
268
+
269
+ window.rootFolderPaths = rootFolderPaths;
270
+ await saveState(state);
271
+
272
+ return window;
273
+ }
274
+
275
+ /**
276
+ * Ensure state file exists, creating if necessary
277
+ * Called on startup to initialize state
278
+ */
279
+ export async function ensureState(): Promise<void> {
280
+ const state = await loadState();
281
+ await saveState(state);
282
+ }