claude-ws 0.4.9-beta.9 → 0.5.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/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-beta.9",
3
+ "version": "0.5.0",
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(activeProjectPath)}&path=${encodeURIComponent(filePath)}`
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: activeProjectPath,
45
- path: filePath,
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
- // Highest priority: explicit queue base URL
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