opencode-account-manager 0.4.1
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/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +183 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/accounts.d.ts +19 -0
- package/dist/core/accounts.d.ts.map +1 -0
- package/dist/core/accounts.js +181 -0
- package/dist/core/accounts.js.map +1 -0
- package/dist/core/config-store.d.ts +48 -0
- package/dist/core/config-store.d.ts.map +1 -0
- package/dist/core/config-store.js +206 -0
- package/dist/core/config-store.js.map +1 -0
- package/dist/core/crypto.d.ts +40 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +172 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/importers/amJson.d.ts +17 -0
- package/dist/core/importers/amJson.d.ts.map +1 -0
- package/dist/core/importers/amJson.js +131 -0
- package/dist/core/importers/amJson.js.map +1 -0
- package/dist/core/opencode-config.d.ts +92 -0
- package/dist/core/opencode-config.d.ts.map +1 -0
- package/dist/core/opencode-config.js +148 -0
- package/dist/core/opencode-config.js.map +1 -0
- package/dist/core/paths.d.ts +5 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/types.d.ts +74 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +30 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/utils.d.ts +5 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +35 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/tui/Dashboard.d.ts +7 -0
- package/dist/tui/Dashboard.d.ts.map +1 -0
- package/dist/tui/Dashboard.js +331 -0
- package/dist/tui/Dashboard.js.map +1 -0
- package/dist/tui/components/AccountList.d.ts +18 -0
- package/dist/tui/components/AccountList.d.ts.map +1 -0
- package/dist/tui/components/AccountList.js +92 -0
- package/dist/tui/components/AccountList.js.map +1 -0
- package/dist/tui/components/Box.d.ts +11 -0
- package/dist/tui/components/Box.d.ts.map +1 -0
- package/dist/tui/components/Box.js +15 -0
- package/dist/tui/components/Box.js.map +1 -0
- package/dist/tui/components/ExportModal.d.ts +10 -0
- package/dist/tui/components/ExportModal.d.ts.map +1 -0
- package/dist/tui/components/ExportModal.js +192 -0
- package/dist/tui/components/ExportModal.js.map +1 -0
- package/dist/tui/components/FileBrowser.d.ts +12 -0
- package/dist/tui/components/FileBrowser.d.ts.map +1 -0
- package/dist/tui/components/FileBrowser.js +349 -0
- package/dist/tui/components/FileBrowser.js.map +1 -0
- package/dist/tui/components/Header.d.ts +8 -0
- package/dist/tui/components/Header.d.ts.map +1 -0
- package/dist/tui/components/Header.js +20 -0
- package/dist/tui/components/Header.js.map +1 -0
- package/dist/tui/components/ImportModal.d.ts +10 -0
- package/dist/tui/components/ImportModal.d.ts.map +1 -0
- package/dist/tui/components/ImportModal.js +215 -0
- package/dist/tui/components/ImportModal.js.map +1 -0
- package/dist/tui/components/McpServerList.d.ts +8 -0
- package/dist/tui/components/McpServerList.d.ts.map +1 -0
- package/dist/tui/components/McpServerList.js +35 -0
- package/dist/tui/components/McpServerList.js.map +1 -0
- package/dist/tui/components/Menu.d.ts +10 -0
- package/dist/tui/components/Menu.d.ts.map +1 -0
- package/dist/tui/components/Menu.js +83 -0
- package/dist/tui/components/Menu.js.map +1 -0
- package/dist/tui/components/PasswordInput.d.ts +12 -0
- package/dist/tui/components/PasswordInput.d.ts.map +1 -0
- package/dist/tui/components/PasswordInput.js +130 -0
- package/dist/tui/components/PasswordInput.js.map +1 -0
- package/dist/tui/components/ProviderList.d.ts +8 -0
- package/dist/tui/components/ProviderList.d.ts.map +1 -0
- package/dist/tui/components/ProviderList.js +37 -0
- package/dist/tui/components/ProviderList.js.map +1 -0
- package/dist/tui/components/SectionBox.d.ts +10 -0
- package/dist/tui/components/SectionBox.d.ts.map +1 -0
- package/dist/tui/components/SectionBox.js +16 -0
- package/dist/tui/components/SectionBox.js.map +1 -0
- package/dist/tui/components/StatsRow.d.ts +13 -0
- package/dist/tui/components/StatsRow.d.ts.map +1 -0
- package/dist/tui/components/StatsRow.js +18 -0
- package/dist/tui/components/StatsRow.js.map +1 -0
- package/dist/tui/components/StatusBadge.d.ts +8 -0
- package/dist/tui/components/StatusBadge.d.ts.map +1 -0
- package/dist/tui/components/StatusBadge.js +30 -0
- package/dist/tui/components/StatusBadge.js.map +1 -0
- package/dist/tui/components/index.d.ts +14 -0
- package/dist/tui/components/index.d.ts.map +1 -0
- package/dist/tui/components/index.js +32 -0
- package/dist/tui/components/index.js.map +1 -0
- package/dist/tui/index.d.ts +5 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +13 -0
- package/dist/tui/index.js.map +1 -0
- package/docs/BLUEPRINT.md +476 -0
- package/docs/ROADMAP.md +74 -0
- package/package.json +38 -0
- package/src/cli.ts +207 -0
- package/src/core/accounts.ts +215 -0
- package/src/core/config-store.ts +212 -0
- package/src/core/crypto.ts +162 -0
- package/src/core/importers/amJson.ts +185 -0
- package/src/core/opencode-config.ts +217 -0
- package/src/core/paths.ts +32 -0
- package/src/core/types.ts +118 -0
- package/src/core/utils.ts +28 -0
- package/src/tui/Dashboard.tsx +431 -0
- package/src/tui/components/AccountList.tsx +155 -0
- package/src/tui/components/Box.tsx +37 -0
- package/src/tui/components/ExportModal.tsx +255 -0
- package/src/tui/components/FileBrowser.tsx +393 -0
- package/src/tui/components/Header.tsx +26 -0
- package/src/tui/components/ImportModal.tsx +288 -0
- package/src/tui/components/McpServerList.tsx +67 -0
- package/src/tui/components/Menu.tsx +103 -0
- package/src/tui/components/PasswordInput.tsx +159 -0
- package/src/tui/components/ProviderList.tsx +61 -0
- package/src/tui/components/SectionBox.tsx +35 -0
- package/src/tui/components/StatsRow.tsx +33 -0
- package/src/tui/components/StatusBadge.tsx +33 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/index.tsx +11 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { FileBrowser } from "./FileBrowser";
|
|
5
|
+
import { PasswordInput } from "./PasswordInput";
|
|
6
|
+
import {
|
|
7
|
+
Account,
|
|
8
|
+
ExportFormat,
|
|
9
|
+
EncryptedExportFile,
|
|
10
|
+
PortableExportFile,
|
|
11
|
+
} from "../../core/types";
|
|
12
|
+
import { encrypt } from "../../core/crypto";
|
|
13
|
+
import { writeJsonFile } from "../../core/utils";
|
|
14
|
+
import {
|
|
15
|
+
updateLastExportFolder,
|
|
16
|
+
getLastExportFolder,
|
|
17
|
+
} from "../../core/config-store";
|
|
18
|
+
|
|
19
|
+
interface ExportModalProps {
|
|
20
|
+
accounts: Account[];
|
|
21
|
+
onComplete: (filePath: string) => void;
|
|
22
|
+
onCancel: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ExportStep = "format" | "folder" | "password" | "exporting" | "success" | "error";
|
|
26
|
+
|
|
27
|
+
export function ExportModal({ accounts, onComplete, onCancel }: ExportModalProps) {
|
|
28
|
+
const [step, setStep] = useState<ExportStep>("format");
|
|
29
|
+
const [format, setFormat] = useState<ExportFormat>("encrypted");
|
|
30
|
+
const [folder, setFolder] = useState<string>("");
|
|
31
|
+
const [exportedPath, setExportedPath] = useState<string>("");
|
|
32
|
+
const [error, setError] = useState<string>("");
|
|
33
|
+
|
|
34
|
+
// Generate filename with timestamp
|
|
35
|
+
const generateFilename = (ext: string): string => {
|
|
36
|
+
const date = new Date();
|
|
37
|
+
const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
38
|
+
return `opencode-accounts-${dateStr}${ext}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Handle format selection
|
|
42
|
+
useInput((input, key) => {
|
|
43
|
+
if (step !== "format") return;
|
|
44
|
+
|
|
45
|
+
if (key.escape) {
|
|
46
|
+
onCancel();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (input === "1") {
|
|
51
|
+
setFormat("encrypted");
|
|
52
|
+
setStep("folder");
|
|
53
|
+
} else if (input === "2") {
|
|
54
|
+
setFormat("plain");
|
|
55
|
+
setStep("folder");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Handle folder selection
|
|
60
|
+
const handleFolderSelect = (selectedFolder: string) => {
|
|
61
|
+
setFolder(selectedFolder);
|
|
62
|
+
updateLastExportFolder(selectedFolder);
|
|
63
|
+
|
|
64
|
+
if (format === "encrypted") {
|
|
65
|
+
setStep("password");
|
|
66
|
+
} else {
|
|
67
|
+
// Plain export - no password needed
|
|
68
|
+
doExport(selectedFolder, undefined);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Handle password submission
|
|
73
|
+
const handlePasswordSubmit = (password: string) => {
|
|
74
|
+
doExport(folder, password);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Do the actual export
|
|
78
|
+
const doExport = (targetFolder: string, password?: string) => {
|
|
79
|
+
setStep("exporting");
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const ext = format === "encrypted" ? ".ocam" : ".json";
|
|
83
|
+
const filename = generateFilename(ext);
|
|
84
|
+
const filePath = path.join(targetFolder, filename);
|
|
85
|
+
|
|
86
|
+
if (format === "encrypted" && password) {
|
|
87
|
+
// Create encrypted export
|
|
88
|
+
const payload = {
|
|
89
|
+
version: 1,
|
|
90
|
+
accounts: accounts,
|
|
91
|
+
exportedAt: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const encrypted = encrypt(payload, password);
|
|
95
|
+
|
|
96
|
+
const encryptedFile: EncryptedExportFile = {
|
|
97
|
+
version: 1,
|
|
98
|
+
format: "encrypted",
|
|
99
|
+
algorithm: "aes-256-gcm",
|
|
100
|
+
salt: encrypted.salt,
|
|
101
|
+
iv: encrypted.iv,
|
|
102
|
+
authTag: encrypted.authTag,
|
|
103
|
+
data: encrypted.data,
|
|
104
|
+
exportedAt: Date.now(),
|
|
105
|
+
accountCount: accounts.length,
|
|
106
|
+
exportedFrom: "opencode-account-manager",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
writeJsonFile(filePath, encryptedFile);
|
|
110
|
+
} else {
|
|
111
|
+
// Plain export
|
|
112
|
+
const plainFile: PortableExportFile = {
|
|
113
|
+
version: 1,
|
|
114
|
+
exportedAt: Date.now(),
|
|
115
|
+
exportedFrom: "opencode-account-manager",
|
|
116
|
+
accounts: accounts,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
writeJsonFile(filePath, plainFile);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setExportedPath(filePath);
|
|
123
|
+
setStep("success");
|
|
124
|
+
|
|
125
|
+
// Auto-complete after 2 seconds
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
onComplete(filePath);
|
|
128
|
+
}, 2000);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
131
|
+
setStep("error");
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Handle success/error dismissal
|
|
136
|
+
useInput((input, key) => {
|
|
137
|
+
if (step === "success" || step === "error") {
|
|
138
|
+
if (key.return || key.escape) {
|
|
139
|
+
if (step === "success") {
|
|
140
|
+
onComplete(exportedPath);
|
|
141
|
+
} else {
|
|
142
|
+
onCancel();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Box flexDirection="column">
|
|
150
|
+
{/* Format Selection */}
|
|
151
|
+
{step === "format" && (
|
|
152
|
+
<Box
|
|
153
|
+
flexDirection="column"
|
|
154
|
+
borderStyle="round"
|
|
155
|
+
borderColor="cyan"
|
|
156
|
+
paddingX={2}
|
|
157
|
+
paddingY={1}
|
|
158
|
+
>
|
|
159
|
+
<Text bold color="cyan">EXPORT {accounts.length} ACCOUNTS</Text>
|
|
160
|
+
<Box marginY={1} flexDirection="column">
|
|
161
|
+
<Text>Select export format:</Text>
|
|
162
|
+
<Box marginTop={1}>
|
|
163
|
+
<Text color="green">[1] Encrypted (.ocam)</Text>
|
|
164
|
+
<Text dimColor> - Password protected, recommended</Text>
|
|
165
|
+
</Box>
|
|
166
|
+
<Box>
|
|
167
|
+
<Text color="yellow">[2] Plain JSON</Text>
|
|
168
|
+
<Text dimColor> - No encryption (tokens visible!)</Text>
|
|
169
|
+
</Box>
|
|
170
|
+
</Box>
|
|
171
|
+
<Box marginTop={1}>
|
|
172
|
+
<Text dimColor>[1-2] Select [Esc] Cancel</Text>
|
|
173
|
+
</Box>
|
|
174
|
+
</Box>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{/* Folder Selection */}
|
|
178
|
+
{step === "folder" && (
|
|
179
|
+
<FileBrowser
|
|
180
|
+
mode="folder"
|
|
181
|
+
initialPath={getLastExportFolder()}
|
|
182
|
+
title={`Export to folder (${format === "encrypted" ? ".ocam" : ".json"})`}
|
|
183
|
+
onSelect={handleFolderSelect}
|
|
184
|
+
onCancel={onCancel}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Password Input */}
|
|
189
|
+
{step === "password" && (
|
|
190
|
+
<PasswordInput
|
|
191
|
+
mode="confirm"
|
|
192
|
+
title="Set Export Password"
|
|
193
|
+
subtitle={`${accounts.length} accounts`}
|
|
194
|
+
warning="Remember this password! Without it, you cannot recover your accounts."
|
|
195
|
+
onSubmit={handlePasswordSubmit}
|
|
196
|
+
onCancel={() => setStep("folder")}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Exporting */}
|
|
201
|
+
{step === "exporting" && (
|
|
202
|
+
<Box
|
|
203
|
+
flexDirection="column"
|
|
204
|
+
borderStyle="round"
|
|
205
|
+
borderColor="cyan"
|
|
206
|
+
paddingX={2}
|
|
207
|
+
paddingY={1}
|
|
208
|
+
>
|
|
209
|
+
<Text color="cyan">Exporting...</Text>
|
|
210
|
+
</Box>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Success */}
|
|
214
|
+
{step === "success" && (
|
|
215
|
+
<Box
|
|
216
|
+
flexDirection="column"
|
|
217
|
+
borderStyle="round"
|
|
218
|
+
borderColor="green"
|
|
219
|
+
paddingX={2}
|
|
220
|
+
paddingY={1}
|
|
221
|
+
>
|
|
222
|
+
<Text bold color="green">✓ Export Successful!</Text>
|
|
223
|
+
<Box marginTop={1}>
|
|
224
|
+
<Text>Exported {accounts.length} accounts to:</Text>
|
|
225
|
+
</Box>
|
|
226
|
+
<Box>
|
|
227
|
+
<Text color="cyan">{exportedPath}</Text>
|
|
228
|
+
</Box>
|
|
229
|
+
<Box marginTop={1}>
|
|
230
|
+
<Text dimColor>[Enter] Close</Text>
|
|
231
|
+
</Box>
|
|
232
|
+
</Box>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Error */}
|
|
236
|
+
{step === "error" && (
|
|
237
|
+
<Box
|
|
238
|
+
flexDirection="column"
|
|
239
|
+
borderStyle="round"
|
|
240
|
+
borderColor="red"
|
|
241
|
+
paddingX={2}
|
|
242
|
+
paddingY={1}
|
|
243
|
+
>
|
|
244
|
+
<Text bold color="red">✗ Export Failed</Text>
|
|
245
|
+
<Box marginTop={1}>
|
|
246
|
+
<Text color="red">{error}</Text>
|
|
247
|
+
</Box>
|
|
248
|
+
<Box marginTop={1}>
|
|
249
|
+
<Text dimColor>[Enter] Close</Text>
|
|
250
|
+
</Box>
|
|
251
|
+
</Box>
|
|
252
|
+
)}
|
|
253
|
+
</Box>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { getQuickLocations, QuickLocation } from "../../core/config-store";
|
|
6
|
+
|
|
7
|
+
interface FileBrowserProps {
|
|
8
|
+
mode: "folder" | "file";
|
|
9
|
+
initialPath?: string;
|
|
10
|
+
extensions?: string[]; // e.g., [".ocam", ".json"]
|
|
11
|
+
title?: string;
|
|
12
|
+
onSelect: (selectedPath: string) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileItem {
|
|
17
|
+
name: string;
|
|
18
|
+
isDirectory: boolean;
|
|
19
|
+
path: string;
|
|
20
|
+
size?: number;
|
|
21
|
+
mtime?: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ViewMode = "quick" | "browse" | "input";
|
|
25
|
+
|
|
26
|
+
export function FileBrowser({
|
|
27
|
+
mode,
|
|
28
|
+
initialPath,
|
|
29
|
+
extensions = [],
|
|
30
|
+
title,
|
|
31
|
+
onSelect,
|
|
32
|
+
onCancel,
|
|
33
|
+
}: FileBrowserProps) {
|
|
34
|
+
const [currentPath, setCurrentPath] = useState(initialPath || process.cwd());
|
|
35
|
+
const [items, setItems] = useState<FileItem[]>([]);
|
|
36
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
37
|
+
const [viewMode, setViewMode] = useState<ViewMode>("quick");
|
|
38
|
+
const [inputPath, setInputPath] = useState("");
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const [quickLocations, setQuickLocations] = useState<QuickLocation[]>([]);
|
|
41
|
+
|
|
42
|
+
// Load quick locations on mount
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setQuickLocations(getQuickLocations());
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
// Load directory contents when path changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (viewMode !== "browse") return;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(currentPath)) {
|
|
53
|
+
setError(`Path not found: ${currentPath}`);
|
|
54
|
+
setItems([]);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stat = fs.statSync(currentPath);
|
|
59
|
+
if (!stat.isDirectory()) {
|
|
60
|
+
setError("Not a directory");
|
|
61
|
+
setItems([]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
66
|
+
const fileItems: FileItem[] = [];
|
|
67
|
+
|
|
68
|
+
// Add parent directory
|
|
69
|
+
const parentPath = path.dirname(currentPath);
|
|
70
|
+
if (parentPath !== currentPath) {
|
|
71
|
+
fileItems.push({
|
|
72
|
+
name: "..",
|
|
73
|
+
isDirectory: true,
|
|
74
|
+
path: parentPath,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Add directories and files
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
81
|
+
const isDir = entry.isDirectory();
|
|
82
|
+
|
|
83
|
+
// Skip hidden files/folders (starting with .)
|
|
84
|
+
if (entry.name.startsWith(".")) continue;
|
|
85
|
+
|
|
86
|
+
// In file mode, filter by extension
|
|
87
|
+
if (mode === "file" && !isDir) {
|
|
88
|
+
if (extensions.length > 0) {
|
|
89
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
90
|
+
if (!extensions.includes(ext)) continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// In folder mode, only show directories
|
|
95
|
+
if (mode === "folder" && !isDir) continue;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const stat = fs.statSync(fullPath);
|
|
99
|
+
fileItems.push({
|
|
100
|
+
name: entry.name,
|
|
101
|
+
isDirectory: isDir,
|
|
102
|
+
path: fullPath,
|
|
103
|
+
size: stat.size,
|
|
104
|
+
mtime: stat.mtime,
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Skip files we can't stat
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Sort: directories first, then files
|
|
112
|
+
fileItems.sort((a, b) => {
|
|
113
|
+
if (a.name === "..") return -1;
|
|
114
|
+
if (b.name === "..") return 1;
|
|
115
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
116
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
117
|
+
return a.name.localeCompare(b.name);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
setItems(fileItems);
|
|
121
|
+
setSelectedIndex(0);
|
|
122
|
+
setError(null);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
setError(`Error reading directory: ${err}`);
|
|
125
|
+
setItems([]);
|
|
126
|
+
}
|
|
127
|
+
}, [currentPath, viewMode, mode, extensions]);
|
|
128
|
+
|
|
129
|
+
useInput((input, key) => {
|
|
130
|
+
// Handle escape
|
|
131
|
+
if (key.escape) {
|
|
132
|
+
if (viewMode === "input") {
|
|
133
|
+
setViewMode("quick");
|
|
134
|
+
setInputPath("");
|
|
135
|
+
} else {
|
|
136
|
+
onCancel();
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle input mode
|
|
142
|
+
if (viewMode === "input") {
|
|
143
|
+
if (key.return) {
|
|
144
|
+
// Try to navigate to input path
|
|
145
|
+
const trimmed = inputPath.trim();
|
|
146
|
+
if (trimmed) {
|
|
147
|
+
if (fs.existsSync(trimmed)) {
|
|
148
|
+
const stat = fs.statSync(trimmed);
|
|
149
|
+
if (mode === "folder" && stat.isDirectory()) {
|
|
150
|
+
onSelect(trimmed);
|
|
151
|
+
} else if (mode === "file" && stat.isFile()) {
|
|
152
|
+
onSelect(trimmed);
|
|
153
|
+
} else if (stat.isDirectory()) {
|
|
154
|
+
setCurrentPath(trimmed);
|
|
155
|
+
setViewMode("browse");
|
|
156
|
+
} else {
|
|
157
|
+
setError("Invalid path for this mode");
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
setError("Path does not exist");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (key.backspace || key.delete) {
|
|
167
|
+
setInputPath(prev => prev.slice(0, -1));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (input && !key.ctrl && !key.meta) {
|
|
172
|
+
setInputPath(prev => prev + input);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Handle quick location mode
|
|
179
|
+
if (viewMode === "quick") {
|
|
180
|
+
// Number keys for quick locations
|
|
181
|
+
const num = parseInt(input, 10);
|
|
182
|
+
if (!isNaN(num) && num >= 1 && num <= quickLocations.length) {
|
|
183
|
+
const loc = quickLocations[num - 1];
|
|
184
|
+
if (mode === "folder") {
|
|
185
|
+
onSelect(loc.path);
|
|
186
|
+
} else {
|
|
187
|
+
setCurrentPath(loc.path);
|
|
188
|
+
setViewMode("browse");
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 'p' for paste path
|
|
194
|
+
if (input.toLowerCase() === "p") {
|
|
195
|
+
setViewMode("input");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 'b' for browse
|
|
200
|
+
if (input.toLowerCase() === "b") {
|
|
201
|
+
setViewMode("browse");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Arrow keys to navigate quick locations
|
|
206
|
+
if (key.upArrow) {
|
|
207
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
208
|
+
}
|
|
209
|
+
if (key.downArrow) {
|
|
210
|
+
setSelectedIndex(prev => Math.min(quickLocations.length - 1, prev + 1));
|
|
211
|
+
}
|
|
212
|
+
if (key.return) {
|
|
213
|
+
const loc = quickLocations[selectedIndex];
|
|
214
|
+
if (loc) {
|
|
215
|
+
if (mode === "folder") {
|
|
216
|
+
onSelect(loc.path);
|
|
217
|
+
} else {
|
|
218
|
+
setCurrentPath(loc.path);
|
|
219
|
+
setViewMode("browse");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Handle browse mode
|
|
227
|
+
if (viewMode === "browse") {
|
|
228
|
+
if (key.upArrow) {
|
|
229
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
230
|
+
}
|
|
231
|
+
if (key.downArrow) {
|
|
232
|
+
setSelectedIndex(prev => Math.min(items.length - 1, prev + 1));
|
|
233
|
+
}
|
|
234
|
+
if (key.return) {
|
|
235
|
+
const item = items[selectedIndex];
|
|
236
|
+
if (!item) return;
|
|
237
|
+
|
|
238
|
+
if (item.isDirectory) {
|
|
239
|
+
if (mode === "folder") {
|
|
240
|
+
// In folder mode, enter selects current folder
|
|
241
|
+
// Navigate into with right arrow
|
|
242
|
+
onSelect(item.path === path.dirname(currentPath) ? currentPath : item.path);
|
|
243
|
+
} else {
|
|
244
|
+
// In file mode, navigate into directory
|
|
245
|
+
setCurrentPath(item.path);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
// Select file
|
|
249
|
+
onSelect(item.path);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (key.rightArrow) {
|
|
253
|
+
const item = items[selectedIndex];
|
|
254
|
+
if (item?.isDirectory && item.name !== "..") {
|
|
255
|
+
setCurrentPath(item.path);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (key.leftArrow) {
|
|
259
|
+
const parent = path.dirname(currentPath);
|
|
260
|
+
if (parent !== currentPath) {
|
|
261
|
+
setCurrentPath(parent);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 'p' for paste path
|
|
266
|
+
if (input.toLowerCase() === "p") {
|
|
267
|
+
setViewMode("input");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 'q' for quick locations
|
|
272
|
+
if (input.toLowerCase() === "q") {
|
|
273
|
+
setViewMode("quick");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const formatSize = (bytes?: number): string => {
|
|
280
|
+
if (bytes === undefined) return "";
|
|
281
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
282
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
|
283
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const truncatePath = (p: string, maxLen: number): string => {
|
|
287
|
+
if (p.length <= maxLen) return p;
|
|
288
|
+
return "..." + p.slice(-(maxLen - 3));
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<Box
|
|
293
|
+
flexDirection="column"
|
|
294
|
+
borderStyle="round"
|
|
295
|
+
borderColor="cyan"
|
|
296
|
+
paddingX={2}
|
|
297
|
+
paddingY={1}
|
|
298
|
+
>
|
|
299
|
+
{/* Title */}
|
|
300
|
+
<Box marginBottom={1}>
|
|
301
|
+
<Text bold color="cyan">
|
|
302
|
+
{title || (mode === "folder" ? "Select Folder" : "Select File")}
|
|
303
|
+
</Text>
|
|
304
|
+
</Box>
|
|
305
|
+
|
|
306
|
+
{/* Current path */}
|
|
307
|
+
<Box marginBottom={1}>
|
|
308
|
+
<Text dimColor>Path: </Text>
|
|
309
|
+
<Text>{truncatePath(currentPath, 50)}</Text>
|
|
310
|
+
</Box>
|
|
311
|
+
|
|
312
|
+
{/* Error */}
|
|
313
|
+
{error && (
|
|
314
|
+
<Box marginBottom={1}>
|
|
315
|
+
<Text color="red">✗ {error}</Text>
|
|
316
|
+
</Box>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{/* Quick Locations View */}
|
|
320
|
+
{viewMode === "quick" && (
|
|
321
|
+
<Box flexDirection="column">
|
|
322
|
+
<Text dimColor>Quick Locations:</Text>
|
|
323
|
+
{quickLocations.map((loc, index) => (
|
|
324
|
+
<Box key={loc.path}>
|
|
325
|
+
<Text color={index === selectedIndex ? "yellow" : "white"}>
|
|
326
|
+
{index === selectedIndex ? "> " : " "}
|
|
327
|
+
[{index + 1}] {loc.label}
|
|
328
|
+
</Text>
|
|
329
|
+
<Text dimColor> ({truncatePath(loc.path, 30)})</Text>
|
|
330
|
+
</Box>
|
|
331
|
+
))}
|
|
332
|
+
<Box marginTop={1}>
|
|
333
|
+
<Text dimColor>[1-{quickLocations.length}] Select [B] Browse [P] Paste path [Esc] Cancel</Text>
|
|
334
|
+
</Box>
|
|
335
|
+
</Box>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* Browse View */}
|
|
339
|
+
{viewMode === "browse" && (
|
|
340
|
+
<Box flexDirection="column">
|
|
341
|
+
<Box flexDirection="column" height={10}>
|
|
342
|
+
{items.slice(
|
|
343
|
+
Math.max(0, selectedIndex - 4),
|
|
344
|
+
Math.max(10, selectedIndex + 6)
|
|
345
|
+
).map((item, displayIndex) => {
|
|
346
|
+
const actualIndex = Math.max(0, selectedIndex - 4) + displayIndex;
|
|
347
|
+
const isSelected = actualIndex === selectedIndex;
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<Box key={item.path}>
|
|
351
|
+
<Text color={isSelected ? "yellow" : "white"}>
|
|
352
|
+
{isSelected ? "> " : " "}
|
|
353
|
+
{item.isDirectory ? "📁 " : "📄 "}
|
|
354
|
+
{item.name}
|
|
355
|
+
</Text>
|
|
356
|
+
{!item.isDirectory && (
|
|
357
|
+
<Text dimColor> ({formatSize(item.size)})</Text>
|
|
358
|
+
)}
|
|
359
|
+
</Box>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</Box>
|
|
363
|
+
<Box marginTop={1}>
|
|
364
|
+
<Text dimColor>
|
|
365
|
+
[↑↓] Navigate [Enter] Select [←→] Navigate dirs [Q] Quick [P] Paste [Esc] Cancel
|
|
366
|
+
</Text>
|
|
367
|
+
</Box>
|
|
368
|
+
</Box>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
{/* Input View */}
|
|
372
|
+
{viewMode === "input" && (
|
|
373
|
+
<Box flexDirection="column">
|
|
374
|
+
<Text dimColor>Paste or type path:</Text>
|
|
375
|
+
<Box
|
|
376
|
+
borderStyle="single"
|
|
377
|
+
borderColor="yellow"
|
|
378
|
+
paddingX={1}
|
|
379
|
+
marginY={1}
|
|
380
|
+
>
|
|
381
|
+
<Text>
|
|
382
|
+
{inputPath}
|
|
383
|
+
<Text color="yellow">▌</Text>
|
|
384
|
+
</Text>
|
|
385
|
+
</Box>
|
|
386
|
+
<Box>
|
|
387
|
+
<Text dimColor>[Enter] Confirm [Esc] Back</Text>
|
|
388
|
+
</Box>
|
|
389
|
+
</Box>
|
|
390
|
+
)}
|
|
391
|
+
</Box>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
interface HeaderProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Header({
|
|
10
|
+
title = "OpenCode Account Manager",
|
|
11
|
+
subtitle,
|
|
12
|
+
}: HeaderProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
15
|
+
<Box>
|
|
16
|
+
<Text bold color="cyan">
|
|
17
|
+
* {title}
|
|
18
|
+
</Text>
|
|
19
|
+
{subtitle && <Text dimColor> - {subtitle}</Text>}
|
|
20
|
+
</Box>
|
|
21
|
+
<Text dimColor>
|
|
22
|
+
────────────────────────────────────────────────────────────────
|
|
23
|
+
</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
);
|
|
26
|
+
}
|