claude-ws 0.4.9-beta.8 → 0.4.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/locales/de.json +7 -0
- package/locales/en.json +7 -0
- package/locales/es.json +7 -0
- package/locales/fr.json +7 -0
- package/locales/ja.json +7 -0
- package/locales/ko.json +7 -0
- package/locales/vi.json +7 -0
- package/locales/zh.json +7 -0
- package/package.json +1 -1
- package/src/app/api/filesystem/route.ts +21 -0
- package/src/components/sidebar/file-browser/file-browser-dialog.tsx +175 -0
- package/src/components/sidebar/file-browser/file-browser-listing.tsx +103 -0
- package/src/components/sidebar/file-browser/file-tree.tsx +17 -0
- package/src/components/sidebar/file-browser/unified-search.tsx +14 -2
- package/src/components/sidebar/file-browser/use-file-tab-content-loader.ts +18 -1
- package/src/components/sidebar/file-browser/use-file-tab-save-copy-download-operations.ts +4 -2
- package/src/hooks/template/hooks/minio-push-sync.ts +1 -16
- package/tsconfig.json +1 -0
package/locales/de.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "Inhalt",
|
|
249
249
|
"allTab": "Alle",
|
|
250
250
|
"refreshFileTree": "Dateibaum aktualisieren",
|
|
251
|
+
"openFile": "Datei öffnen",
|
|
252
|
+
"browseAndSelectFile": "Durchsuchen Sie Ihr Dateisystem und wählen Sie eine Datei",
|
|
253
|
+
"openSelectedFile": "Öffnen",
|
|
254
|
+
"upDirectory": "Hoch",
|
|
255
|
+
"homeDirectory": "Start",
|
|
256
|
+
"emptyDirectory": "Dieses Verzeichnis ist leer",
|
|
257
|
+
"showHidden": "Versteckte",
|
|
251
258
|
"noResults": "Keine Ergebnisse gefunden",
|
|
252
259
|
"searchInFile": "In Datei suchen",
|
|
253
260
|
"commits": "Commits",
|
package/locales/en.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "Content",
|
|
249
249
|
"allTab": "All",
|
|
250
250
|
"refreshFileTree": "Refresh file tree",
|
|
251
|
+
"openFile": "Open File",
|
|
252
|
+
"browseAndSelectFile": "Browse your filesystem and select a file to open",
|
|
253
|
+
"openSelectedFile": "Open",
|
|
254
|
+
"upDirectory": "Up",
|
|
255
|
+
"homeDirectory": "Home",
|
|
256
|
+
"emptyDirectory": "This directory is empty",
|
|
257
|
+
"showHidden": "Hidden",
|
|
251
258
|
"noResults": "No results found",
|
|
252
259
|
"searchInFile": "Search in file",
|
|
253
260
|
"commits": "Commits",
|
package/locales/es.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "Contenido",
|
|
249
249
|
"allTab": "Todos",
|
|
250
250
|
"refreshFileTree": "Actualizar árbol de archivos",
|
|
251
|
+
"openFile": "Abrir archivo",
|
|
252
|
+
"browseAndSelectFile": "Explore su sistema de archivos y seleccione un archivo",
|
|
253
|
+
"openSelectedFile": "Abrir",
|
|
254
|
+
"upDirectory": "Arriba",
|
|
255
|
+
"homeDirectory": "Inicio",
|
|
256
|
+
"emptyDirectory": "Este directorio está vacío",
|
|
257
|
+
"showHidden": "Ocultos",
|
|
251
258
|
"noResults": "No se encontraron resultados",
|
|
252
259
|
"searchInFile": "Buscar en archivo",
|
|
253
260
|
"commits": "Confirmaciones",
|
package/locales/fr.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "Contenu",
|
|
249
249
|
"allTab": "Tout",
|
|
250
250
|
"refreshFileTree": "Actualiser l'arborescence",
|
|
251
|
+
"openFile": "Ouvrir un fichier",
|
|
252
|
+
"browseAndSelectFile": "Parcourez votre système de fichiers et sélectionnez un fichier",
|
|
253
|
+
"openSelectedFile": "Ouvrir",
|
|
254
|
+
"upDirectory": "Remonter",
|
|
255
|
+
"homeDirectory": "Accueil",
|
|
256
|
+
"emptyDirectory": "Ce répertoire est vide",
|
|
257
|
+
"showHidden": "Masqués",
|
|
251
258
|
"noResults": "Aucun résultat trouvé",
|
|
252
259
|
"searchInFile": "Rechercher dans le fichier",
|
|
253
260
|
"commits": "Commits",
|
package/locales/ja.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "コンテンツ",
|
|
249
249
|
"allTab": "すべて",
|
|
250
250
|
"refreshFileTree": "ファイルツリーを更新",
|
|
251
|
+
"openFile": "ファイルを開く",
|
|
252
|
+
"browseAndSelectFile": "ファイルシステムを参照してファイルを選択",
|
|
253
|
+
"openSelectedFile": "開く",
|
|
254
|
+
"upDirectory": "上へ",
|
|
255
|
+
"homeDirectory": "ホーム",
|
|
256
|
+
"emptyDirectory": "このディレクトリは空です",
|
|
257
|
+
"showHidden": "隠しファイル",
|
|
251
258
|
"noResults": "結果が見つかりませんでした",
|
|
252
259
|
"searchInFile": "ファイル内を検索",
|
|
253
260
|
"commits": "コミット",
|
package/locales/ko.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "콘텐츠",
|
|
249
249
|
"allTab": "모두",
|
|
250
250
|
"refreshFileTree": "파일 트리 새로고침",
|
|
251
|
+
"openFile": "파일 열기",
|
|
252
|
+
"browseAndSelectFile": "파일 시스템을 탐색하고 열 파일을 선택하세요",
|
|
253
|
+
"openSelectedFile": "열기",
|
|
254
|
+
"upDirectory": "위로",
|
|
255
|
+
"homeDirectory": "홈",
|
|
256
|
+
"emptyDirectory": "이 디렉토리는 비어 있습니다",
|
|
257
|
+
"showHidden": "숨김",
|
|
251
258
|
"noResults": "결과를 찾을 수 없음",
|
|
252
259
|
"searchInFile": "파일에서 검색",
|
|
253
260
|
"commits": "커밋",
|
package/locales/vi.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "Nội dung",
|
|
249
249
|
"allTab": "Tất cả",
|
|
250
250
|
"refreshFileTree": "Làm mới cây tập tin",
|
|
251
|
+
"openFile": "Mở tệp",
|
|
252
|
+
"browseAndSelectFile": "Duyệt hệ thống tệp và chọn tệp để mở",
|
|
253
|
+
"openSelectedFile": "Mở",
|
|
254
|
+
"upDirectory": "Lên",
|
|
255
|
+
"homeDirectory": "Home",
|
|
256
|
+
"emptyDirectory": "Thư mục này trống",
|
|
257
|
+
"showHidden": "Ẩn",
|
|
251
258
|
"noResults": "Không tìm thấy kết quả",
|
|
252
259
|
"searchInFile": "Tìm trong tập tin",
|
|
253
260
|
"commits": "Lần gửi",
|
package/locales/zh.json
CHANGED
|
@@ -248,6 +248,13 @@
|
|
|
248
248
|
"contentTab": "内容",
|
|
249
249
|
"allTab": "全部",
|
|
250
250
|
"refreshFileTree": "刷新文件树",
|
|
251
|
+
"openFile": "打开文件",
|
|
252
|
+
"browseAndSelectFile": "浏览文件系统并选择要打开的文件",
|
|
253
|
+
"openSelectedFile": "打开",
|
|
254
|
+
"upDirectory": "上级",
|
|
255
|
+
"homeDirectory": "主目录",
|
|
256
|
+
"emptyDirectory": "此目录为空",
|
|
257
|
+
"showHidden": "隐藏",
|
|
251
258
|
"noResults": "未找到结果",
|
|
252
259
|
"searchInFile": "在文件中搜索",
|
|
253
260
|
"commits": "提交",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-ws",
|
|
3
|
-
"version": "0.4.9
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "AI-powered workspace for solo CEOs and indie builders — manage your entire business with AI agents, not just code. Kanban board, code editor, Git integration, claw agent hub, local-first SQLite.",
|
|
6
6
|
"keywords": [
|
|
@@ -31,6 +31,8 @@ export async function GET(request: NextRequest) {
|
|
|
31
31
|
|
|
32
32
|
// Filter to only directories, exclude hidden by default
|
|
33
33
|
const showHidden = searchParams.get('showHidden') === 'true';
|
|
34
|
+
const includeFiles = searchParams.get('includeFiles') === 'true';
|
|
35
|
+
|
|
34
36
|
const directories = entries
|
|
35
37
|
.filter((entry) => {
|
|
36
38
|
if (!entry.isDirectory()) return false;
|
|
@@ -44,6 +46,24 @@ export async function GET(request: NextRequest) {
|
|
|
44
46
|
}))
|
|
45
47
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
46
48
|
|
|
49
|
+
// Optionally include files in the response
|
|
50
|
+
let files: { name: string; path: string; isDirectory: boolean; size: number }[] = [];
|
|
51
|
+
if (includeFiles) {
|
|
52
|
+
files = entries
|
|
53
|
+
.filter((entry) => {
|
|
54
|
+
if (!entry.isFile()) return false;
|
|
55
|
+
if (!showHidden && entry.name.startsWith('.')) return false;
|
|
56
|
+
return true;
|
|
57
|
+
})
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
const filePath = path.join(dirPath, entry.name);
|
|
60
|
+
let size = 0;
|
|
61
|
+
try { size = fs.statSync(filePath).size; } catch {}
|
|
62
|
+
return { name: entry.name, path: filePath, isDirectory: false, size };
|
|
63
|
+
})
|
|
64
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
// Get parent directory
|
|
48
68
|
const parentPath = path.dirname(dirPath);
|
|
49
69
|
const canGoUp = parentPath !== dirPath;
|
|
@@ -52,6 +72,7 @@ export async function GET(request: NextRequest) {
|
|
|
52
72
|
currentPath: dirPath,
|
|
53
73
|
parentPath: canGoUp ? parentPath : null,
|
|
54
74
|
directories,
|
|
75
|
+
...(includeFiles ? { files } : {}),
|
|
55
76
|
homePath: os.homedir(),
|
|
56
77
|
});
|
|
57
78
|
} catch (error) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@/components/ui/dialog';
|
|
12
|
+
import { Button } from '@/components/ui/button';
|
|
13
|
+
import { Input } from '@/components/ui/input';
|
|
14
|
+
import { ChevronUp, Home, RefreshCw, Eye } from 'lucide-react';
|
|
15
|
+
import { cn } from '@/lib/utils';
|
|
16
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
17
|
+
import { FileBrowserListing, type FileBrowserEntry } from './file-browser-listing';
|
|
18
|
+
|
|
19
|
+
interface FileBrowserDialogProps {
|
|
20
|
+
open: boolean;
|
|
21
|
+
onOpenChange: (open: boolean) => void;
|
|
22
|
+
onFileSelect: (filePath: string) => void;
|
|
23
|
+
initialPath?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dialog for browsing the entire filesystem and selecting a file to open.
|
|
28
|
+
* Similar to FolderBrowserDialog but shows files alongside directories.
|
|
29
|
+
* Directories navigate deeper; clicking a file selects it; "Open" opens it in the editor.
|
|
30
|
+
*/
|
|
31
|
+
export function FileBrowserDialog({ open, onOpenChange, onFileSelect, initialPath }: FileBrowserDialogProps) {
|
|
32
|
+
const t = useTranslations('sidebar');
|
|
33
|
+
const tCommon = useTranslations('common');
|
|
34
|
+
|
|
35
|
+
const [currentPath, setCurrentPath] = useState(initialPath || '');
|
|
36
|
+
const [directories, setDirectories] = useState<FileBrowserEntry[]>([]);
|
|
37
|
+
const [files, setFiles] = useState<FileBrowserEntry[]>([]);
|
|
38
|
+
const [parentPath, setParentPath] = useState<string | null>(null);
|
|
39
|
+
const [homePath, setHomePath] = useState('');
|
|
40
|
+
const [loading, setLoading] = useState(false);
|
|
41
|
+
const [error, setError] = useState('');
|
|
42
|
+
const [manualPath, setManualPath] = useState('');
|
|
43
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
44
|
+
const [showHidden, setShowHidden] = useState(true);
|
|
45
|
+
|
|
46
|
+
const fetchDirectory = useCallback(async (path?: string, hidden?: boolean) => {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setError('');
|
|
49
|
+
setSelectedFile(null);
|
|
50
|
+
const shouldShowHidden = hidden ?? showHidden;
|
|
51
|
+
try {
|
|
52
|
+
const params = new URLSearchParams({ includeFiles: 'true' });
|
|
53
|
+
if (path) params.set('path', path);
|
|
54
|
+
if (shouldShowHidden) params.set('showHidden', 'true');
|
|
55
|
+
const response = await fetch(`/api/filesystem?${params.toString()}`);
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
if (!response.ok) throw new Error(data.error || 'Failed to load directory');
|
|
58
|
+
setCurrentPath(data.currentPath);
|
|
59
|
+
setDirectories(data.directories || []);
|
|
60
|
+
setFiles(data.files || []);
|
|
61
|
+
setParentPath(data.parentPath);
|
|
62
|
+
setHomePath(data.homePath);
|
|
63
|
+
setManualPath(data.currentPath);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError(err instanceof Error ? err.message : 'Failed to load directory');
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (open) fetchDirectory(initialPath || undefined);
|
|
73
|
+
}, [open, initialPath, fetchDirectory]);
|
|
74
|
+
|
|
75
|
+
const handleToggleHidden = useCallback(() => {
|
|
76
|
+
const next = !showHidden;
|
|
77
|
+
setShowHidden(next);
|
|
78
|
+
fetchDirectory(currentPath || undefined, next);
|
|
79
|
+
}, [showHidden, currentPath, fetchDirectory]);
|
|
80
|
+
|
|
81
|
+
const handleSelectFile = useCallback((entry: FileBrowserEntry) => {
|
|
82
|
+
setSelectedFile(entry.path);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const handleOpenFile = useCallback((entry: FileBrowserEntry) => {
|
|
86
|
+
onFileSelect(entry.path);
|
|
87
|
+
onOpenChange(false);
|
|
88
|
+
}, [onFileSelect, onOpenChange]);
|
|
89
|
+
|
|
90
|
+
const handleOpen = useCallback(() => {
|
|
91
|
+
if (selectedFile) {
|
|
92
|
+
onFileSelect(selectedFile);
|
|
93
|
+
onOpenChange(false);
|
|
94
|
+
}
|
|
95
|
+
}, [selectedFile, onFileSelect, onOpenChange]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
99
|
+
<DialogContent className="sm:max-w-[600px] h-[600px] flex flex-col overflow-hidden">
|
|
100
|
+
<DialogHeader>
|
|
101
|
+
<DialogTitle>{t('openFile')}</DialogTitle>
|
|
102
|
+
<DialogDescription>{t('browseAndSelectFile')}</DialogDescription>
|
|
103
|
+
</DialogHeader>
|
|
104
|
+
|
|
105
|
+
{/* Manual path input */}
|
|
106
|
+
<form
|
|
107
|
+
onSubmit={(e) => { e.preventDefault(); if (manualPath.trim()) fetchDirectory(manualPath.trim()); }}
|
|
108
|
+
className="flex gap-2"
|
|
109
|
+
>
|
|
110
|
+
<Input
|
|
111
|
+
value={manualPath}
|
|
112
|
+
onChange={(e) => setManualPath(e.target.value)}
|
|
113
|
+
placeholder="/path/to/directory"
|
|
114
|
+
className="flex-1"
|
|
115
|
+
/>
|
|
116
|
+
<Button type="submit" variant="outline" size="icon" disabled={loading}>
|
|
117
|
+
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
|
118
|
+
</Button>
|
|
119
|
+
</form>
|
|
120
|
+
|
|
121
|
+
{/* Navigation buttons */}
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
<Button
|
|
124
|
+
variant="outline" size="sm"
|
|
125
|
+
onClick={() => parentPath && fetchDirectory(parentPath)}
|
|
126
|
+
disabled={!parentPath || loading}
|
|
127
|
+
>
|
|
128
|
+
<ChevronUp className="h-4 w-4 mr-1" />{t('upDirectory')}
|
|
129
|
+
</Button>
|
|
130
|
+
<Button
|
|
131
|
+
variant="outline" size="sm"
|
|
132
|
+
onClick={() => fetchDirectory(homePath)}
|
|
133
|
+
disabled={loading}
|
|
134
|
+
>
|
|
135
|
+
<Home className="h-4 w-4 mr-1" />{t('homeDirectory')}
|
|
136
|
+
</Button>
|
|
137
|
+
<div className="flex-1" />
|
|
138
|
+
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
|
139
|
+
<Checkbox
|
|
140
|
+
checked={showHidden}
|
|
141
|
+
onCheckedChange={handleToggleHidden}
|
|
142
|
+
/>
|
|
143
|
+
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
|
144
|
+
<span className="text-xs text-muted-foreground">{t('showHidden')}</span>
|
|
145
|
+
</label>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{error && (
|
|
149
|
+
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-950 p-2 rounded">{error}</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* File/directory listing */}
|
|
153
|
+
<FileBrowserListing
|
|
154
|
+
loading={loading}
|
|
155
|
+
directories={directories}
|
|
156
|
+
files={files}
|
|
157
|
+
selectedFile={selectedFile}
|
|
158
|
+
onNavigate={fetchDirectory}
|
|
159
|
+
onSelectFile={handleSelectFile}
|
|
160
|
+
onOpenFile={handleOpenFile}
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
{/* Action buttons */}
|
|
164
|
+
<div className="flex justify-end gap-2 pt-2">
|
|
165
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
166
|
+
{tCommon('cancel')}
|
|
167
|
+
</Button>
|
|
168
|
+
<Button onClick={handleOpen} disabled={!selectedFile}>
|
|
169
|
+
{t('openSelectedFile')}
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
172
|
+
</DialogContent>
|
|
173
|
+
</Dialog>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Folder, Loader2 } from 'lucide-react';
|
|
4
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
5
|
+
import { FileIcon } from './file-icon';
|
|
6
|
+
import { useTranslations } from 'next-intl';
|
|
7
|
+
|
|
8
|
+
export interface FileBrowserEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
isDirectory: boolean;
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FileBrowserListingProps {
|
|
16
|
+
loading: boolean;
|
|
17
|
+
directories: FileBrowserEntry[];
|
|
18
|
+
files: FileBrowserEntry[];
|
|
19
|
+
selectedFile: string | null;
|
|
20
|
+
onNavigate: (path: string) => void;
|
|
21
|
+
onSelectFile: (entry: FileBrowserEntry) => void;
|
|
22
|
+
onOpenFile: (entry: FileBrowserEntry) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scrollable listing of directories and files for the file browser dialog.
|
|
27
|
+
* Directories navigate on click; files are selectable (highlighted on click, opened on double-click).
|
|
28
|
+
*/
|
|
29
|
+
export function FileBrowserListing({
|
|
30
|
+
loading,
|
|
31
|
+
directories,
|
|
32
|
+
files,
|
|
33
|
+
selectedFile,
|
|
34
|
+
onNavigate,
|
|
35
|
+
onSelectFile,
|
|
36
|
+
onOpenFile,
|
|
37
|
+
}: FileBrowserListingProps) {
|
|
38
|
+
const t = useTranslations('sidebar');
|
|
39
|
+
|
|
40
|
+
const isEmpty = directories.length === 0 && files.length === 0;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex-1 min-h-0 border rounded-md overflow-hidden">
|
|
44
|
+
<ScrollArea className="h-full">
|
|
45
|
+
{loading ? (
|
|
46
|
+
<div className="flex items-center justify-center h-[200px]">
|
|
47
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
48
|
+
</div>
|
|
49
|
+
) : isEmpty ? (
|
|
50
|
+
<div className="flex items-center justify-center h-[200px] text-muted-foreground text-sm">
|
|
51
|
+
{t('emptyDirectory')}
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<div className="p-2 space-y-0.5">
|
|
55
|
+
{/* Directories first */}
|
|
56
|
+
{directories.map((dir) => (
|
|
57
|
+
<button
|
|
58
|
+
key={dir.path}
|
|
59
|
+
onDoubleClick={() => onNavigate(dir.path)}
|
|
60
|
+
onClick={() => onNavigate(dir.path)}
|
|
61
|
+
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors text-left min-w-0"
|
|
62
|
+
>
|
|
63
|
+
<Folder className="h-4 w-4 text-blue-500 shrink-0" />
|
|
64
|
+
<span className="truncate text-sm">{dir.name}</span>
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
|
|
68
|
+
{/* Files */}
|
|
69
|
+
{files.map((file) => {
|
|
70
|
+
const isSelected = selectedFile === file.path;
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
key={file.path}
|
|
74
|
+
onClick={() => onSelectFile(file)}
|
|
75
|
+
onDoubleClick={() => onOpenFile(file)}
|
|
76
|
+
className={`w-full flex items-center gap-2 p-2 rounded-md transition-colors text-left min-w-0 ${
|
|
77
|
+
isSelected
|
|
78
|
+
? 'bg-primary/10 ring-1 ring-primary/30'
|
|
79
|
+
: 'hover:bg-muted'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
<FileIcon name={file.name} type="file" isExpanded={false} className="h-4 w-4 shrink-0" />
|
|
83
|
+
<span className="truncate text-sm flex-1">{file.name}</span>
|
|
84
|
+
{file.size !== undefined && (
|
|
85
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
86
|
+
{formatFileSize(file.size)}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</ScrollArea>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatFileSize(bytes: number): string {
|
|
100
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
101
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
102
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
103
|
+
}
|
|
@@ -9,6 +9,7 @@ import { useSidebarStore } from '@/stores/sidebar-store';
|
|
|
9
9
|
import { useActiveProject } from '@/hooks/use-active-project';
|
|
10
10
|
import type { FileEntry } from '@/types';
|
|
11
11
|
import { FileCreateButtons } from './file-create-buttons';
|
|
12
|
+
import { FileBrowserDialog } from './file-browser-dialog';
|
|
12
13
|
import { useTranslations } from 'next-intl';
|
|
13
14
|
|
|
14
15
|
interface FileTreeProps {
|
|
@@ -26,6 +27,7 @@ export function FileTree({ onFileSelect }: FileTreeProps) {
|
|
|
26
27
|
const [error, setError] = useState<string | null>(null);
|
|
27
28
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
28
29
|
const [searchResults, setSearchResults] = useState<SearchResults | null>(null);
|
|
30
|
+
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
|
29
31
|
const isComponentMountedRef = useRef(true); const fetchedKeyRef = useRef<string | null>(null);
|
|
30
32
|
|
|
31
33
|
// Fetch file tree
|
|
@@ -97,6 +99,12 @@ export function FileTree({ onFileSelect }: FileTreeProps) {
|
|
|
97
99
|
|
|
98
100
|
const handleSearchChange = useCallback((results: SearchResults | null) => { setSearchResults(results); }, []);
|
|
99
101
|
|
|
102
|
+
const handleOpenFileBrowser = useCallback(() => { setFileBrowserOpen(true); }, []);
|
|
103
|
+
|
|
104
|
+
const handleFileBrowserSelect = useCallback((filePath: string) => {
|
|
105
|
+
handleFileClick(filePath);
|
|
106
|
+
}, [handleFileClick]);
|
|
107
|
+
|
|
100
108
|
// Get root directory entry for creating files/folders at project root
|
|
101
109
|
const rootEntry: FileEntry = {
|
|
102
110
|
name: activeProject?.path?.split('/').pop() || 'root',
|
|
@@ -184,6 +192,7 @@ export function FileTree({ onFileSelect }: FileTreeProps) {
|
|
|
184
192
|
className="flex-1"
|
|
185
193
|
onRefresh={handleRefresh}
|
|
186
194
|
refreshing={loading}
|
|
195
|
+
onOpenFile={handleOpenFileBrowser}
|
|
187
196
|
/>
|
|
188
197
|
</div>
|
|
189
198
|
|
|
@@ -195,6 +204,14 @@ export function FileTree({ onFileSelect }: FileTreeProps) {
|
|
|
195
204
|
<div className="py-1">{renderTree(entries)}</div>
|
|
196
205
|
)}
|
|
197
206
|
</ScrollArea>
|
|
207
|
+
|
|
208
|
+
{/* File browser dialog for opening files from anywhere on the filesystem */}
|
|
209
|
+
<FileBrowserDialog
|
|
210
|
+
open={fileBrowserOpen}
|
|
211
|
+
onOpenChange={setFileBrowserOpen}
|
|
212
|
+
onFileSelect={handleFileBrowserSelect}
|
|
213
|
+
initialPath={activeProject?.path}
|
|
214
|
+
/>
|
|
198
215
|
</div>
|
|
199
216
|
);
|
|
200
217
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import { Search, X, FileText, FileCode, RefreshCw } from 'lucide-react';
|
|
4
|
+
import { Search, X, FileText, FileCode, RefreshCw, FolderOpen } from 'lucide-react';
|
|
5
5
|
import { useTranslations } from 'next-intl';
|
|
6
6
|
import { Input } from '@/components/ui/input';
|
|
7
7
|
import { Button } from '@/components/ui/button';
|
|
@@ -26,13 +26,14 @@ interface UnifiedSearchProps {
|
|
|
26
26
|
className?: string;
|
|
27
27
|
onRefresh?: () => void;
|
|
28
28
|
refreshing?: boolean;
|
|
29
|
+
onOpenFile?: () => void;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* UnifiedSearch - Search input with mode filter tabs (files / content / all).
|
|
33
34
|
* Calls onSearchChange with results; SearchResultsView renders them separately.
|
|
34
35
|
*/
|
|
35
|
-
export function UnifiedSearch({ onSearchChange, className, onRefresh, refreshing }: UnifiedSearchProps) {
|
|
36
|
+
export function UnifiedSearch({ onSearchChange, className, onRefresh, refreshing, onOpenFile }: UnifiedSearchProps) {
|
|
36
37
|
const t = useTranslations('sidebar');
|
|
37
38
|
const activeProject = useActiveProject();
|
|
38
39
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
@@ -129,6 +130,17 @@ export function UnifiedSearch({ onSearchChange, className, onRefresh, refreshing
|
|
|
129
130
|
<X className="size-3" />
|
|
130
131
|
</Button>
|
|
131
132
|
)}
|
|
133
|
+
{onOpenFile && (
|
|
134
|
+
<Button
|
|
135
|
+
variant="ghost"
|
|
136
|
+
size="icon"
|
|
137
|
+
className="size-6"
|
|
138
|
+
onClick={onOpenFile}
|
|
139
|
+
title={t('openFile')}
|
|
140
|
+
>
|
|
141
|
+
<FolderOpen className="size-3" />
|
|
142
|
+
</Button>
|
|
143
|
+
)}
|
|
132
144
|
{onRefresh && (
|
|
133
145
|
<Button
|
|
134
146
|
variant="ghost"
|
|
@@ -10,6 +10,22 @@ interface UseFileTabContentLoaderOptions {
|
|
|
10
10
|
onReset: () => void;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve basePath and relative path for the file content API.
|
|
15
|
+
* If filePath is absolute and outside the project, use the file's parent dir as basePath.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveFileApiPaths(filePath: string, projectPath: string) {
|
|
18
|
+
const isAbsolute = filePath.startsWith('/');
|
|
19
|
+
if (isAbsolute && !filePath.startsWith(projectPath + '/') && filePath !== projectPath) {
|
|
20
|
+
const lastSlash = filePath.lastIndexOf('/');
|
|
21
|
+
return {
|
|
22
|
+
basePath: filePath.substring(0, lastSlash) || '/',
|
|
23
|
+
relativePath: filePath.substring(lastSlash + 1),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { basePath: projectPath, relativePath: filePath };
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* useFileTabContentLoader - Fetches file content from the server when filePath changes.
|
|
15
31
|
* Calls onLoaded with the response data or onReset when filePath/project is cleared.
|
|
@@ -41,8 +57,9 @@ export function useFileTabContentLoader({
|
|
|
41
57
|
setLoading(true);
|
|
42
58
|
setError(null);
|
|
43
59
|
try {
|
|
60
|
+
const { basePath, relativePath } = resolveFileApiPaths(filePath, activeProjectPath);
|
|
44
61
|
const res = await fetch(
|
|
45
|
-
`/api/files/content?basePath=${encodeURIComponent(
|
|
62
|
+
`/api/files/content?basePath=${encodeURIComponent(basePath)}&path=${encodeURIComponent(relativePath)}`
|
|
46
63
|
);
|
|
47
64
|
if (!res.ok) {
|
|
48
65
|
const data = await res.json();
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useEffect } from 'react';
|
|
4
4
|
import type { FileContent } from './use-file-tab-state';
|
|
5
|
+
import { resolveFileApiPaths } from './use-file-tab-content-loader';
|
|
5
6
|
|
|
6
7
|
interface UseFileTabSaveCopyDownloadOperationsOptions {
|
|
7
8
|
filePath: string;
|
|
@@ -37,12 +38,13 @@ export function useFileTabSaveCopyDownloadOperations({
|
|
|
37
38
|
|
|
38
39
|
setSaveStatus('saving');
|
|
39
40
|
try {
|
|
41
|
+
const { basePath, relativePath } = resolveFileApiPaths(filePath, activeProjectPath);
|
|
40
42
|
const res = await fetch('/api/files/content', {
|
|
41
43
|
method: 'POST',
|
|
42
44
|
headers: { 'Content-Type': 'application/json' },
|
|
43
45
|
body: JSON.stringify({
|
|
44
|
-
basePath
|
|
45
|
-
path:
|
|
46
|
+
basePath,
|
|
47
|
+
path: relativePath,
|
|
46
48
|
content: editedContent,
|
|
47
49
|
}),
|
|
48
50
|
});
|
|
@@ -89,7 +89,6 @@ let config: {
|
|
|
89
89
|
apiHookApiKey: string;
|
|
90
90
|
apiQueueUrl: string;
|
|
91
91
|
apiQueueKey: string;
|
|
92
|
-
appPort: string;
|
|
93
92
|
projectId: string;
|
|
94
93
|
targetPrefix: string;
|
|
95
94
|
};
|
|
@@ -104,7 +103,6 @@ function initializeRuntimeConfig() {
|
|
|
104
103
|
apiHookApiKey: (process.env.API_HOOK_API_KEY || "").trim(),
|
|
105
104
|
apiQueueUrl,
|
|
106
105
|
apiQueueKey: (process.env.API_ACCESS_KEY || "").trim(),
|
|
107
|
-
appPort: queuePort,
|
|
108
106
|
projectId: PROJECT_ID,
|
|
109
107
|
targetPrefix: PROJECT_ID,
|
|
110
108
|
};
|
|
@@ -151,24 +149,11 @@ function buildQueueCandidates(): string[] {
|
|
|
151
149
|
}
|
|
152
150
|
};
|
|
153
151
|
|
|
154
|
-
//
|
|
152
|
+
// Queue endpoint is always local: localhost + PORT
|
|
155
153
|
if (config.apiQueueUrl) {
|
|
156
154
|
push(config.apiQueueUrl);
|
|
157
155
|
}
|
|
158
156
|
|
|
159
|
-
// Default queue endpoint: localhost + PORT from environment
|
|
160
|
-
if (!config.apiQueueUrl && config.appPort) {
|
|
161
|
-
push(`http://localhost:${config.appPort}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Last fallback: same origin as sync API base
|
|
165
|
-
try {
|
|
166
|
-
const origin = new URL(config.apiBaseUrl).origin;
|
|
167
|
-
push(origin);
|
|
168
|
-
} catch {
|
|
169
|
-
// ignore invalid URL
|
|
170
|
-
}
|
|
171
|
-
|
|
172
157
|
return candidates;
|
|
173
158
|
}
|
|
174
159
|
|