arbors 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/.claude-plugin/plugin.json +7 -0
- package/.oxlintrc.json +9 -0
- package/README.ja.md +131 -0
- package/README.ko.md +131 -0
- package/README.md +131 -0
- package/bin/arbors.ts +278 -0
- package/dist/arbors.js +1094 -0
- package/dist/arbors.js.map +1 -0
- package/dist/bun-EMN2NS2M.js +48 -0
- package/dist/bun-EMN2NS2M.js.map +1 -0
- package/dist/ja-F4DBSAAZ.js +38 -0
- package/dist/ja-F4DBSAAZ.js.map +1 -0
- package/dist/ko-MTIAHJOR.js +38 -0
- package/dist/ko-MTIAHJOR.js.map +1 -0
- package/dist/node-LCODN3HC.js +56 -0
- package/dist/node-LCODN3HC.js.map +1 -0
- package/package.json +54 -0
- package/pnpm-workspace.yaml +1 -0
- package/shell/arbors-wrapper.sh +21 -0
- package/shell/arbors-wrapper.zsh +21 -0
- package/skills/arbors-usage/SKILL.md +129 -0
- package/src/config.ts +66 -0
- package/src/git/exclude.ts +63 -0
- package/src/git/safety.ts +40 -0
- package/src/git/worktree.ts +171 -0
- package/src/i18n/en.ts +63 -0
- package/src/i18n/index.ts +37 -0
- package/src/i18n/ja.ts +40 -0
- package/src/i18n/ko.ts +40 -0
- package/src/project/registry.ts +108 -0
- package/src/project/setup.ts +74 -0
- package/src/runtime/adapter.ts +16 -0
- package/src/runtime/bun.ts +49 -0
- package/src/runtime/index.ts +17 -0
- package/src/runtime/node.ts +58 -0
- package/src/tui/App.tsx +87 -0
- package/src/tui/FuzzyList.tsx +111 -0
- package/src/tui/ProjectSelector.tsx +48 -0
- package/src/tui/WorktreeSelector.tsx +46 -0
- package/tests/config.test.ts +108 -0
- package/tests/exclude.test.ts +120 -0
- package/tests/i18n.test.ts +75 -0
- package/tests/registry.test.ts +136 -0
- package/tests/safety.test.ts +58 -0
- package/tests/setup-detection.test.ts +105 -0
- package/tests/setup.test.ts +87 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
4
|
+
|
|
5
|
+
export interface ProjectEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
lastAccessed: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WorktreeEntry {
|
|
12
|
+
path: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
projectPath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RegistryData {
|
|
18
|
+
projects: ProjectEntry[];
|
|
19
|
+
worktrees: WorktreeEntry[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DB_PATH = join(homedir(), ".arbors", "db.json");
|
|
23
|
+
|
|
24
|
+
const readRegistry = async (adapter: RuntimeAdapter): Promise<RegistryData> => {
|
|
25
|
+
if (!(await adapter.exists(DB_PATH))) return { projects: [], worktrees: [] };
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const content = await adapter.readFile(DB_PATH);
|
|
29
|
+
const data = JSON.parse(content) as Partial<RegistryData>;
|
|
30
|
+
return { projects: data.projects ?? [], worktrees: data.worktrees ?? [] };
|
|
31
|
+
} catch {
|
|
32
|
+
return { projects: [], worktrees: [] };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const writeRegistry = async (adapter: RuntimeAdapter, data: RegistryData): Promise<void> => {
|
|
37
|
+
await adapter.writeFile(DB_PATH, JSON.stringify(data, null, 2));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const getProjects = async (adapter: RuntimeAdapter): Promise<ProjectEntry[]> =>
|
|
41
|
+
(await readRegistry(adapter)).projects.toSorted(
|
|
42
|
+
(a, b) => new Date(b.lastAccessed).getTime() - new Date(a.lastAccessed).getTime(),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export const registerProject = async (
|
|
46
|
+
adapter: RuntimeAdapter,
|
|
47
|
+
name: string,
|
|
48
|
+
path: string,
|
|
49
|
+
): Promise<void> => {
|
|
50
|
+
const data = await readRegistry(adapter);
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
const existing = data.projects.findIndex((p) => p.path === path);
|
|
54
|
+
|
|
55
|
+
if (existing !== -1) {
|
|
56
|
+
data.projects[existing] = { ...data.projects[existing], lastAccessed: now };
|
|
57
|
+
} else {
|
|
58
|
+
data.projects.push({ name, path, lastAccessed: now });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await writeRegistry(adapter, data);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const touchProject = async (adapter: RuntimeAdapter, path: string): Promise<void> => {
|
|
65
|
+
const data = await readRegistry(adapter);
|
|
66
|
+
const entry = data.projects.find((p) => p.path === path);
|
|
67
|
+
|
|
68
|
+
if (entry) {
|
|
69
|
+
entry.lastAccessed = new Date().toISOString();
|
|
70
|
+
await writeRegistry(adapter, data);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const removeProject = async (adapter: RuntimeAdapter, path: string): Promise<void> => {
|
|
75
|
+
const data = await readRegistry(adapter);
|
|
76
|
+
data.projects = data.projects.filter((p) => p.path !== path);
|
|
77
|
+
await writeRegistry(adapter, data);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const registerWorktree = async (
|
|
81
|
+
adapter: RuntimeAdapter,
|
|
82
|
+
path: string,
|
|
83
|
+
branch: string,
|
|
84
|
+
projectPath: string,
|
|
85
|
+
): Promise<void> => {
|
|
86
|
+
const data = await readRegistry(adapter);
|
|
87
|
+
const existing = data.worktrees.findIndex((w) => w.path === path);
|
|
88
|
+
|
|
89
|
+
if (existing === -1) {
|
|
90
|
+
data.worktrees.push({ path, branch, projectPath });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await writeRegistry(adapter, data);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const getWorktrees = async (
|
|
97
|
+
adapter: RuntimeAdapter,
|
|
98
|
+
projectPath: string,
|
|
99
|
+
): Promise<WorktreeEntry[]> => {
|
|
100
|
+
const data = await readRegistry(adapter);
|
|
101
|
+
return data.worktrees.filter((w) => w.projectPath === projectPath);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const unregisterWorktree = async (adapter: RuntimeAdapter, path: string): Promise<void> => {
|
|
105
|
+
const data = await readRegistry(adapter);
|
|
106
|
+
data.worktrees = data.worktrees.filter((w) => w.path !== path);
|
|
107
|
+
await writeRegistry(adapter, data);
|
|
108
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
3
|
+
|
|
4
|
+
type PackageManager = "pnpm" | "yarn" | "npm" | null;
|
|
5
|
+
type RuntimeManager = "mise" | "nvm" | null;
|
|
6
|
+
|
|
7
|
+
const LOCK_FILE_MAP: [string, PackageManager][] = [
|
|
8
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
9
|
+
["yarn.lock", "yarn"],
|
|
10
|
+
["package-lock.json", "npm"],
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const RUNTIME_MANAGER_MAP: [string, RuntimeManager][] = [
|
|
14
|
+
["mise.toml", "mise"],
|
|
15
|
+
[".mise.toml", "mise"],
|
|
16
|
+
[".nvmrc", "nvm"],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const detectFile = async <T>(
|
|
20
|
+
adapter: RuntimeAdapter,
|
|
21
|
+
cwd: string,
|
|
22
|
+
entries: [string, T][],
|
|
23
|
+
): Promise<T | null> => {
|
|
24
|
+
const results = await Promise.all(
|
|
25
|
+
entries.map(async ([file, value]) => ({
|
|
26
|
+
value,
|
|
27
|
+
exists: await adapter.exists(join(cwd, file)),
|
|
28
|
+
})),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return results.find((r) => r.exists)?.value ?? null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const detectPackageManager = async (
|
|
35
|
+
adapter: RuntimeAdapter,
|
|
36
|
+
cwd: string,
|
|
37
|
+
): Promise<PackageManager> => detectFile(adapter, cwd, LOCK_FILE_MAP);
|
|
38
|
+
|
|
39
|
+
export const detectRuntimeManager = async (
|
|
40
|
+
adapter: RuntimeAdapter,
|
|
41
|
+
cwd: string,
|
|
42
|
+
): Promise<RuntimeManager> => detectFile(adapter, cwd, RUNTIME_MANAGER_MAP);
|
|
43
|
+
|
|
44
|
+
const PM_INSTALL_ARGS: Record<string, string[]> = {
|
|
45
|
+
pnpm: ["install"],
|
|
46
|
+
yarn: ["install"],
|
|
47
|
+
npm: ["install"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const RM_INSTALL_ARGS: Record<string, [string, string[]]> = {
|
|
51
|
+
mise: ["mise", ["install"]],
|
|
52
|
+
nvm: ["bash", ["-c", "source ~/.nvm/nvm.sh && nvm install"]],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const runSetup = async (
|
|
56
|
+
adapter: RuntimeAdapter,
|
|
57
|
+
cwd: string,
|
|
58
|
+
configPm?: "auto" | "pnpm" | "yarn" | "npm",
|
|
59
|
+
): Promise<{ packageManager: string | null; runtimeManager: string | null }> => {
|
|
60
|
+
const rm = await detectRuntimeManager(adapter, cwd);
|
|
61
|
+
|
|
62
|
+
if (rm) {
|
|
63
|
+
const [cmd, args] = RM_INSTALL_ARGS[rm];
|
|
64
|
+
await adapter.exec(cmd, args);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pm = configPm && configPm !== "auto" ? configPm : await detectPackageManager(adapter, cwd);
|
|
68
|
+
|
|
69
|
+
if (pm) {
|
|
70
|
+
await adapter.exec(pm, PM_INSTALL_ARGS[pm]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { packageManager: pm, runtimeManager: rm };
|
|
74
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ExecResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RuntimeAdapter {
|
|
8
|
+
/** Run a command with arguments (no shell, safe from injection) */
|
|
9
|
+
exec(cmd: string, args: string[]): Promise<ExecResult>;
|
|
10
|
+
glob(pattern: string, cwd: string): Promise<string[]>;
|
|
11
|
+
readFile(path: string): Promise<string>;
|
|
12
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
13
|
+
exists(path: string): Promise<boolean>;
|
|
14
|
+
copy(src: string, dest: string): Promise<void>;
|
|
15
|
+
mkdir(path: string): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import type { RuntimeAdapter } from "./adapter.js";
|
|
3
|
+
|
|
4
|
+
export const createBunAdapter = (): RuntimeAdapter => ({
|
|
5
|
+
async exec(cmd, args) {
|
|
6
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
7
|
+
stdout: "pipe",
|
|
8
|
+
stderr: "pipe",
|
|
9
|
+
});
|
|
10
|
+
const [stdout, stderr] = await Promise.all([
|
|
11
|
+
new Response(proc.stdout).text(),
|
|
12
|
+
new Response(proc.stderr).text(),
|
|
13
|
+
]);
|
|
14
|
+
const exitCode = await proc.exited;
|
|
15
|
+
return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode };
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async glob(pattern, cwd) {
|
|
19
|
+
const bunGlob = new Bun.Glob(pattern);
|
|
20
|
+
const matches: string[] = [];
|
|
21
|
+
for await (const entry of bunGlob.scan({ cwd })) {
|
|
22
|
+
matches.push(entry);
|
|
23
|
+
}
|
|
24
|
+
return matches.sort();
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async readFile(path) {
|
|
28
|
+
return Bun.file(path).text();
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async writeFile(path, content) {
|
|
32
|
+
await Bun.write(path, content);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async exists(path) {
|
|
36
|
+
return Bun.file(path).exists();
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async copy(src, dest) {
|
|
40
|
+
const { cp, mkdir } = await import("node:fs/promises");
|
|
41
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
42
|
+
await cp(src, dest, { recursive: true });
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async mkdir(path) {
|
|
46
|
+
const { mkdir } = await import("node:fs/promises");
|
|
47
|
+
await mkdir(path, { recursive: true });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RuntimeAdapter } from "./adapter.js";
|
|
2
|
+
|
|
3
|
+
export type { ExecResult, RuntimeAdapter } from "./adapter.js";
|
|
4
|
+
|
|
5
|
+
const isBun = (): boolean => Object.hasOwn(process.versions, "bun");
|
|
6
|
+
|
|
7
|
+
export const createAdapter = async (runtime?: "bun" | "node"): Promise<RuntimeAdapter> => {
|
|
8
|
+
const resolved = runtime ?? (isBun() ? "bun" : "node");
|
|
9
|
+
|
|
10
|
+
if (resolved === "bun") {
|
|
11
|
+
const { createBunAdapter } = await import("./bun.js");
|
|
12
|
+
return createBunAdapter();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { createNodeAdapter } = await import("./node.js");
|
|
16
|
+
return createNodeAdapter();
|
|
17
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { cp, glob, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import type { RuntimeAdapter } from "./adapter.js";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export const createNodeAdapter = (): RuntimeAdapter => ({
|
|
10
|
+
async exec(cmd, args) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout, stderr } = await execFileAsync(cmd, args);
|
|
13
|
+
return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode: 0 };
|
|
14
|
+
} catch (error: unknown) {
|
|
15
|
+
const err = error as { stdout?: string; stderr?: string; code?: number };
|
|
16
|
+
return {
|
|
17
|
+
stdout: (err.stdout ?? "").trimEnd(),
|
|
18
|
+
stderr: (err.stderr ?? "").trimEnd(),
|
|
19
|
+
exitCode: err.code ?? 1,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async glob(pattern, cwd) {
|
|
25
|
+
const matches: string[] = [];
|
|
26
|
+
for await (const entry of glob(pattern, { cwd })) {
|
|
27
|
+
matches.push(entry);
|
|
28
|
+
}
|
|
29
|
+
return matches.sort();
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async readFile(path) {
|
|
33
|
+
return readFile(path, "utf-8");
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async writeFile(path, content) {
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
await writeFile(path, content, "utf-8");
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async exists(path) {
|
|
42
|
+
try {
|
|
43
|
+
await stat(path);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async copy(src, dest) {
|
|
51
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
52
|
+
await cp(src, dest, { recursive: true });
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async mkdir(path) {
|
|
56
|
+
await mkdir(path, { recursive: true });
|
|
57
|
+
},
|
|
58
|
+
});
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useApp } from "ink";
|
|
3
|
+
import { ProjectSelector } from "./ProjectSelector.js";
|
|
4
|
+
import { WorktreeSelector } from "./WorktreeSelector.js";
|
|
5
|
+
import type { ProjectEntry } from "../project/registry.js";
|
|
6
|
+
import type { WorktreeInfo } from "../git/worktree.js";
|
|
7
|
+
import type { Messages } from "../i18n/en.js";
|
|
8
|
+
import type { RuntimeAdapter } from "../runtime/adapter.js";
|
|
9
|
+
|
|
10
|
+
type AppState =
|
|
11
|
+
| { phase: "project"; projects: ProjectEntry[] }
|
|
12
|
+
| { phase: "worktree"; project: ProjectEntry; worktrees: WorktreeInfo[] }
|
|
13
|
+
| { phase: "result"; message: string };
|
|
14
|
+
|
|
15
|
+
interface AppProps {
|
|
16
|
+
adapter: RuntimeAdapter;
|
|
17
|
+
messages: Messages;
|
|
18
|
+
projects: ProjectEntry[];
|
|
19
|
+
listWorktrees: (adapter: RuntimeAdapter) => Promise<WorktreeInfo[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const App: React.FC<AppProps> = ({ adapter, messages, projects, listWorktrees }) => {
|
|
23
|
+
const { exit } = useApp();
|
|
24
|
+
const [state, setState] = useState<AppState>({ phase: "project", projects });
|
|
25
|
+
|
|
26
|
+
const handleProjectSelect = async (project: ProjectEntry) => {
|
|
27
|
+
const worktrees = await listWorktrees(adapter);
|
|
28
|
+
const managedWorktrees = worktrees.filter((wt) => !wt.isMain);
|
|
29
|
+
setState({ phase: "worktree", project, worktrees: managedWorktrees });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const handleWorktreeSelect = (worktree: WorktreeInfo) => {
|
|
33
|
+
setState({ phase: "result", message: worktree.path });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleWorktreeCreate = () => {
|
|
37
|
+
setState({ phase: "result", message: "create" });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handleWorktreeDelete = (_worktree: WorktreeInfo) => {
|
|
41
|
+
// Deletion flow handled by parent process
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleBack = () => {
|
|
45
|
+
setState({ phase: "project", projects });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Auto-exit after result is displayed
|
|
49
|
+
const resultMessage = state.phase === "result" ? state.message : null;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (resultMessage) {
|
|
52
|
+
// Print the selected path for shell wrapper to capture
|
|
53
|
+
console.log(`__ARBORS_CD__:${resultMessage}`);
|
|
54
|
+
exit();
|
|
55
|
+
}
|
|
56
|
+
}, [resultMessage, exit]);
|
|
57
|
+
|
|
58
|
+
if (state.phase === "project") {
|
|
59
|
+
return (
|
|
60
|
+
<ProjectSelector
|
|
61
|
+
projects={state.projects}
|
|
62
|
+
onSelect={handleProjectSelect}
|
|
63
|
+
onCancel={() => exit()}
|
|
64
|
+
messages={messages}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (state.phase === "worktree") {
|
|
70
|
+
return (
|
|
71
|
+
<WorktreeSelector
|
|
72
|
+
worktrees={state.worktrees}
|
|
73
|
+
onSelect={handleWorktreeSelect}
|
|
74
|
+
onCreate={handleWorktreeCreate}
|
|
75
|
+
onDelete={handleWorktreeDelete}
|
|
76
|
+
onCancel={handleBack}
|
|
77
|
+
messages={messages}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Box>
|
|
84
|
+
<Text dimColor>{state.message}</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import Fuse from "fuse.js";
|
|
4
|
+
|
|
5
|
+
export interface FuzzyListItem {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
meta?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FuzzyListProps {
|
|
12
|
+
items: FuzzyListItem[];
|
|
13
|
+
onSelect: (item: FuzzyListItem) => void;
|
|
14
|
+
onCancel: () => void;
|
|
15
|
+
title: string;
|
|
16
|
+
helpText: string;
|
|
17
|
+
maxResults?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_VISIBLE = 10;
|
|
21
|
+
|
|
22
|
+
export const FuzzyList: React.FC<FuzzyListProps> = ({
|
|
23
|
+
items,
|
|
24
|
+
onSelect,
|
|
25
|
+
onCancel,
|
|
26
|
+
title,
|
|
27
|
+
helpText,
|
|
28
|
+
maxResults = MAX_VISIBLE,
|
|
29
|
+
}) => {
|
|
30
|
+
const [query, setQuery] = useState("");
|
|
31
|
+
const [cursor, setCursor] = useState(0);
|
|
32
|
+
|
|
33
|
+
const fuse = useMemo(
|
|
34
|
+
() => new Fuse(items, { keys: ["label", "value"], threshold: 0.4 }),
|
|
35
|
+
[items],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const filtered = useMemo(() => {
|
|
39
|
+
if (query === "") return items.slice(0, maxResults);
|
|
40
|
+
return fuse
|
|
41
|
+
.search(query)
|
|
42
|
+
.slice(0, maxResults)
|
|
43
|
+
.map((r) => r.item);
|
|
44
|
+
}, [query, items, fuse, maxResults]);
|
|
45
|
+
|
|
46
|
+
useInput((input, key) => {
|
|
47
|
+
if (key.escape) {
|
|
48
|
+
onCancel();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (key.return && filtered.length > 0) {
|
|
53
|
+
onSelect(filtered[cursor]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (key.tab && filtered.length > 0) {
|
|
58
|
+
setQuery(filtered[0].label);
|
|
59
|
+
setCursor(0);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (key.upArrow) {
|
|
64
|
+
setCursor((prev) => Math.max(0, prev - 1));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (key.downArrow) {
|
|
69
|
+
setCursor((prev) => Math.min(filtered.length - 1, prev + 1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (key.backspace || key.delete) {
|
|
74
|
+
setQuery((prev) => prev.slice(0, -1));
|
|
75
|
+
setCursor(0);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (input && !key.ctrl && !key.meta) {
|
|
80
|
+
setQuery((prev) => prev + input);
|
|
81
|
+
setCursor(0);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Box flexDirection="column">
|
|
87
|
+
<Text bold>{title}</Text>
|
|
88
|
+
<Box>
|
|
89
|
+
<Text dimColor>{"> "}</Text>
|
|
90
|
+
<Text>{query}</Text>
|
|
91
|
+
<Text dimColor>{"█"}</Text>
|
|
92
|
+
</Box>
|
|
93
|
+
|
|
94
|
+
{filtered.length === 0 ? (
|
|
95
|
+
<Text dimColor> No matches</Text>
|
|
96
|
+
) : (
|
|
97
|
+
filtered.map((item, i) => (
|
|
98
|
+
<Box key={item.value}>
|
|
99
|
+
<Text color={i === cursor ? "cyan" : undefined}>{i === cursor ? "→ " : " "}</Text>
|
|
100
|
+
<Text bold={i === cursor}>{item.label}</Text>
|
|
101
|
+
{item.meta ? <Text dimColor>{` (${item.meta})`}</Text> : null}
|
|
102
|
+
</Box>
|
|
103
|
+
))
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<Box marginTop={1}>
|
|
107
|
+
<Text dimColor>{helpText}</Text>
|
|
108
|
+
</Box>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { FuzzyList } from "./FuzzyList.js";
|
|
4
|
+
import type { FuzzyListItem } from "./FuzzyList.js";
|
|
5
|
+
import type { ProjectEntry } from "../project/registry.js";
|
|
6
|
+
import type { Messages } from "../i18n/en.js";
|
|
7
|
+
|
|
8
|
+
interface ProjectSelectorProps {
|
|
9
|
+
projects: ProjectEntry[];
|
|
10
|
+
onSelect: (project: ProjectEntry) => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
messages: Messages;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|
16
|
+
projects,
|
|
17
|
+
onSelect,
|
|
18
|
+
onCancel,
|
|
19
|
+
messages,
|
|
20
|
+
}) => {
|
|
21
|
+
if (projects.length === 0) {
|
|
22
|
+
return <Text>{messages.noProjects}</Text>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const items: FuzzyListItem[] = projects.map((p) => ({
|
|
26
|
+
label: p.name,
|
|
27
|
+
value: p.path,
|
|
28
|
+
meta: new Intl.DateTimeFormat(undefined, {
|
|
29
|
+
dateStyle: "short",
|
|
30
|
+
timeStyle: "short",
|
|
31
|
+
}).format(new Date(p.lastAccessed)),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const handleSelect = (item: FuzzyListItem) => {
|
|
35
|
+
const project = projects.find((p) => p.path === item.value);
|
|
36
|
+
if (project) onSelect(project);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<FuzzyList
|
|
41
|
+
items={items}
|
|
42
|
+
onSelect={handleSelect}
|
|
43
|
+
onCancel={onCancel}
|
|
44
|
+
title={messages.selectProject}
|
|
45
|
+
helpText={messages.helpFooter}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { FuzzyList } from "./FuzzyList.js";
|
|
4
|
+
import type { FuzzyListItem } from "./FuzzyList.js";
|
|
5
|
+
import type { WorktreeInfo } from "../git/worktree.js";
|
|
6
|
+
import type { Messages } from "../i18n/en.js";
|
|
7
|
+
|
|
8
|
+
interface WorktreeSelectorProps {
|
|
9
|
+
worktrees: WorktreeInfo[];
|
|
10
|
+
onSelect: (worktree: WorktreeInfo) => void;
|
|
11
|
+
onCreate: () => void;
|
|
12
|
+
onDelete: (worktree: WorktreeInfo) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
messages: Messages;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WorktreeSelector: React.FC<WorktreeSelectorProps> = ({
|
|
18
|
+
worktrees,
|
|
19
|
+
onSelect,
|
|
20
|
+
onCancel,
|
|
21
|
+
messages,
|
|
22
|
+
}) => {
|
|
23
|
+
if (worktrees.length === 0) {
|
|
24
|
+
return <Text>{messages.noWorktrees}</Text>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const items: FuzzyListItem[] = worktrees.map((wt) => ({
|
|
28
|
+
label: wt.branch,
|
|
29
|
+
value: wt.path,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const handleSelect = (item: FuzzyListItem) => {
|
|
33
|
+
const worktree = worktrees.find((wt) => wt.path === item.value);
|
|
34
|
+
if (worktree) onSelect(worktree);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<FuzzyList
|
|
39
|
+
items={items}
|
|
40
|
+
onSelect={handleSelect}
|
|
41
|
+
onCancel={onCancel}
|
|
42
|
+
title={messages.selectWorktree}
|
|
43
|
+
helpText={messages.helpWorktree}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
};
|