idea-manager 0.8.1 → 0.8.3
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/package.json +1 -1
- package/public/sw.js +1 -1
- package/src/app/api/filesystem/tree/route.ts +78 -0
- package/src/app/globals.css +10 -0
- package/src/cli.ts +1 -0
- package/src/components/task/ProjectTree.tsx +39 -9
- package/src/components/task/TaskChat.tsx +22 -0
- package/src/components/ui/FileTreeDrawer.tsx +221 -0
- package/src/components/workspace/WorkspacePanel.tsx +20 -3
package/package.json
CHANGED
package/public/sw.js
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const IGNORED = new Set([
|
|
6
|
+
'node_modules', '.git', '.next', 'dist', 'build', '__pycache__',
|
|
7
|
+
'.cache', '.tmp', '.DS_Store', 'coverage', '.turbo', '.vercel',
|
|
8
|
+
'.idea', '.vscode', '.svn', '.hg',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
interface TreeEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
type: 'file' | 'directory';
|
|
15
|
+
size?: number;
|
|
16
|
+
extension?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function GET(request: NextRequest) {
|
|
20
|
+
const dirPath = request.nextUrl.searchParams.get('path');
|
|
21
|
+
|
|
22
|
+
if (!dirPath) {
|
|
23
|
+
return NextResponse.json({ error: 'path is required' }, { status: 400 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const resolved = path.resolve(dirPath);
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(resolved)) {
|
|
30
|
+
return NextResponse.json({ error: 'Path does not exist' }, { status: 404 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stat = fs.statSync(resolved);
|
|
34
|
+
if (!stat.isDirectory()) {
|
|
35
|
+
return NextResponse.json({ error: 'Not a directory' }, { status: 400 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true });
|
|
39
|
+
const items: TreeEntry[] = [];
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
|
|
43
|
+
|
|
44
|
+
const fullPath = path.join(resolved, entry.name);
|
|
45
|
+
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
items.push({
|
|
48
|
+
name: entry.name,
|
|
49
|
+
path: fullPath,
|
|
50
|
+
type: 'directory',
|
|
51
|
+
});
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
try {
|
|
54
|
+
const fileStat = fs.statSync(fullPath);
|
|
55
|
+
items.push({
|
|
56
|
+
name: entry.name,
|
|
57
|
+
path: fullPath,
|
|
58
|
+
type: 'file',
|
|
59
|
+
size: fileStat.size,
|
|
60
|
+
extension: path.extname(entry.name).slice(1).toLowerCase() || undefined,
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// skip unreadable files
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sort: directories first, then alphabetically
|
|
69
|
+
items.sort((a, b) => {
|
|
70
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
71
|
+
return a.name.localeCompare(b.name);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return NextResponse.json({ path: resolved, entries: items });
|
|
75
|
+
} catch {
|
|
76
|
+
return NextResponse.json({ error: 'Cannot read directory' }, { status: 500 });
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/app/globals.css
CHANGED
|
@@ -1048,3 +1048,13 @@ textarea:focus {
|
|
|
1048
1048
|
.animate-dialog-in {
|
|
1049
1049
|
animation: dialogIn 0.15s ease-out;
|
|
1050
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
/* Drawer slide-in from right */
|
|
1053
|
+
@keyframes drawerIn {
|
|
1054
|
+
from { transform: translateX(100%); }
|
|
1055
|
+
to { transform: translateX(0); }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.animate-drawer-in {
|
|
1059
|
+
animation: drawerIn 0.2s ease-out;
|
|
1060
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,15 @@ import { CSS } from '@dnd-kit/utilities';
|
|
|
20
20
|
import type { ITask, ISubProjectWithStats, TaskStatus } from '@/types';
|
|
21
21
|
import { statusIcon } from './StatusFlow';
|
|
22
22
|
|
|
23
|
+
function subProjectStatus(sp: ISubProjectWithStats): { dotClass: string; label: string; title: string } | null {
|
|
24
|
+
if (sp.task_count === 0) return null;
|
|
25
|
+
if (sp.problem_count > 0) return { dotClass: 'bg-destructive', label: `${sp.problem_count}!`, title: `${sp.problem_count} problem` };
|
|
26
|
+
if (sp.done_count === sp.task_count) return { dotClass: 'bg-success', label: `${sp.done_count}/${sp.task_count}`, title: 'All done' };
|
|
27
|
+
if (sp.done_count > 0) return { dotClass: 'bg-primary', label: `${sp.done_count}/${sp.task_count}`, title: `${sp.done_count} done, ${sp.active_count} active` };
|
|
28
|
+
if (sp.active_count > 0) return { dotClass: 'bg-warning', label: `${sp.active_count}`, title: `${sp.active_count} in progress` };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
24
33
|
high: 'bg-destructive',
|
|
25
34
|
medium: 'bg-warning',
|
|
@@ -100,13 +109,25 @@ export default function ProjectTree({
|
|
|
100
109
|
<div className="flex flex-col h-full">
|
|
101
110
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border flex-shrink-0">
|
|
102
111
|
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Projects</h2>
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
<div className="flex items-center gap-2">
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => {
|
|
115
|
+
const allCollapsed = subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id));
|
|
116
|
+
setCollapsedSubs(allCollapsed ? new Set() : new Set(subProjects.map(sp => sp.id)));
|
|
117
|
+
}}
|
|
118
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
119
|
+
title={subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id)) ? 'Expand all' : 'Collapse all'}
|
|
120
|
+
>
|
|
121
|
+
{subProjects.length > 0 && subProjects.every(sp => collapsedSubs.has(sp.id)) ? '\u25B6' : '\u25BC'}
|
|
122
|
+
</button>
|
|
123
|
+
<button
|
|
124
|
+
onClick={onCreateSub}
|
|
125
|
+
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
126
|
+
title="Add sub-project (N)"
|
|
127
|
+
>
|
|
128
|
+
+ <span className="text-muted-foreground/50">N</span>
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
110
131
|
</div>
|
|
111
132
|
|
|
112
133
|
<div className="flex-1 overflow-y-auto py-1">
|
|
@@ -205,8 +226,12 @@ function SortableSubProject({
|
|
|
205
226
|
{/* Sub-project node */}
|
|
206
227
|
<div
|
|
207
228
|
onClick={() => {
|
|
208
|
-
|
|
209
|
-
|
|
229
|
+
if (isSelected) {
|
|
230
|
+
onToggleCollapse(sp.id);
|
|
231
|
+
} else {
|
|
232
|
+
onSelectSub(sp.id);
|
|
233
|
+
if (isCollapsed) onToggleCollapse(sp.id);
|
|
234
|
+
}
|
|
210
235
|
}}
|
|
211
236
|
className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition-colors group text-sm ${
|
|
212
237
|
isSelected
|
|
@@ -230,6 +255,11 @@ function SortableSubProject({
|
|
|
230
255
|
>
|
|
231
256
|
{isCollapsed ? '\u25B6' : '\u25BC'}
|
|
232
257
|
</button>
|
|
258
|
+
{(() => {
|
|
259
|
+
const st = subProjectStatus(sp);
|
|
260
|
+
if (!st) return null;
|
|
261
|
+
return <span className={`w-2 h-2 rounded-full ${st.dotClass} flex-shrink-0`} title={st.title} />;
|
|
262
|
+
})()}
|
|
233
263
|
<span className={`flex-1 truncate font-medium ${isSelected ? 'text-primary' : ''}`}>
|
|
234
264
|
{sp.name}
|
|
235
265
|
</span>
|
|
@@ -7,6 +7,18 @@ import remarkGfm from 'remark-gfm';
|
|
|
7
7
|
|
|
8
8
|
const POLL_INTERVAL = 3000; // Poll every 3s when task is testing
|
|
9
9
|
|
|
10
|
+
function notifyAiResponse(preview: string) {
|
|
11
|
+
// Only notify when window/tab is not focused
|
|
12
|
+
if (document.hasFocus()) return;
|
|
13
|
+
|
|
14
|
+
if (Notification.permission === 'granted') {
|
|
15
|
+
new Notification('IM - AI Response', {
|
|
16
|
+
body: preview.slice(0, 120),
|
|
17
|
+
icon: '/icon-192.png',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export default function TaskChat({
|
|
11
23
|
basePath,
|
|
12
24
|
taskStatus,
|
|
@@ -22,6 +34,13 @@ export default function TaskChat({
|
|
|
22
34
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
23
35
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
24
36
|
|
|
37
|
+
// Request notification permission on mount
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
40
|
+
Notification.requestPermission();
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
25
44
|
const fetchMessages = useCallback(() => {
|
|
26
45
|
fetch(`${basePath}/chat`)
|
|
27
46
|
.then(r => r.json())
|
|
@@ -73,6 +92,9 @@ export default function TaskChat({
|
|
|
73
92
|
const withoutTemp = prev.filter(m => m.id !== tempId);
|
|
74
93
|
return [...withoutTemp, data.userMessage, data.aiMessage];
|
|
75
94
|
});
|
|
95
|
+
if (data.aiMessage?.content) {
|
|
96
|
+
notifyAiResponse(data.aiMessage.content);
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
} catch { /* silent */ }
|
|
78
100
|
setLoading(false);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface TreeEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
type: 'file' | 'directory';
|
|
9
|
+
size?: number;
|
|
10
|
+
extension?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DirNode {
|
|
14
|
+
entries: TreeEntry[];
|
|
15
|
+
loaded: boolean;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const FILE_ICONS: Record<string, string> = {
|
|
21
|
+
ts: 'TS', tsx: 'TX', js: 'JS', jsx: 'JX',
|
|
22
|
+
json: '{}', md: 'MD', css: 'CS', scss: 'SC',
|
|
23
|
+
html: 'HT', svg: 'SV', png: 'PN', jpg: 'JP',
|
|
24
|
+
py: 'PY', go: 'GO', rs: 'RS', java: 'JA',
|
|
25
|
+
sql: 'SQ', sh: 'SH', yml: 'YM', yaml: 'YM',
|
|
26
|
+
toml: 'TM', xml: 'XM', txt: 'TX', env: 'EN',
|
|
27
|
+
lock: 'LK', gitignore: 'GI',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function formatSize(bytes: number): string {
|
|
31
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
32
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
|
|
33
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getFileIcon(ext?: string): string {
|
|
37
|
+
if (!ext) return '--';
|
|
38
|
+
return FILE_ICONS[ext] || ext.slice(0, 2).toUpperCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function FileTreeDrawer({
|
|
42
|
+
rootPath,
|
|
43
|
+
onClose,
|
|
44
|
+
}: {
|
|
45
|
+
rootPath: string;
|
|
46
|
+
onClose: () => void;
|
|
47
|
+
}) {
|
|
48
|
+
const [dirs, setDirs] = useState<Record<string, DirNode>>({});
|
|
49
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set([rootPath]));
|
|
50
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
const loadDir = useCallback(async (dirPath: string) => {
|
|
53
|
+
setDirs(prev => ({
|
|
54
|
+
...prev,
|
|
55
|
+
[dirPath]: { entries: [], loaded: false, loading: true },
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`/api/filesystem/tree?path=${encodeURIComponent(dirPath)}`);
|
|
60
|
+
if (!res.ok) throw new Error('Failed to load');
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
setDirs(prev => ({
|
|
63
|
+
...prev,
|
|
64
|
+
[dirPath]: { entries: data.entries, loaded: true, loading: false },
|
|
65
|
+
}));
|
|
66
|
+
} catch {
|
|
67
|
+
setDirs(prev => ({
|
|
68
|
+
...prev,
|
|
69
|
+
[dirPath]: { entries: [], loaded: true, loading: false, error: 'Failed to load' },
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// Load root on mount
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
loadDir(rootPath);
|
|
77
|
+
}, [rootPath, loadDir]);
|
|
78
|
+
|
|
79
|
+
// ESC to close
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const handler = (e: KeyboardEvent) => {
|
|
82
|
+
if (e.key === 'Escape') onClose();
|
|
83
|
+
};
|
|
84
|
+
window.addEventListener('keydown', handler);
|
|
85
|
+
return () => window.removeEventListener('keydown', handler);
|
|
86
|
+
}, [onClose]);
|
|
87
|
+
|
|
88
|
+
const toggleDir = (dirPath: string) => {
|
|
89
|
+
setExpanded(prev => {
|
|
90
|
+
const next = new Set(prev);
|
|
91
|
+
if (next.has(dirPath)) {
|
|
92
|
+
next.delete(dirPath);
|
|
93
|
+
} else {
|
|
94
|
+
next.add(dirPath);
|
|
95
|
+
// Load if not yet loaded
|
|
96
|
+
if (!dirs[dirPath]?.loaded && !dirs[dirPath]?.loading) {
|
|
97
|
+
loadDir(dirPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return next;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const renderEntries = (dirPath: string, depth: number) => {
|
|
105
|
+
const node = dirs[dirPath];
|
|
106
|
+
if (!node) return null;
|
|
107
|
+
|
|
108
|
+
if (node.loading) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
|
|
111
|
+
<span className="text-xs text-muted-foreground animate-pulse">Loading...</span>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (node.error) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
|
|
119
|
+
<span className="text-xs text-destructive">{node.error}</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (node.entries.length === 0) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex items-center gap-2 py-1" style={{ paddingLeft: depth * 16 + 12 }}>
|
|
127
|
+
<span className="text-xs text-muted-foreground italic">Empty</span>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return node.entries.map((entry) => {
|
|
133
|
+
const isDir = entry.type === 'directory';
|
|
134
|
+
const isExpanded = expanded.has(entry.path);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div key={entry.path}>
|
|
138
|
+
<div
|
|
139
|
+
className={`flex items-center gap-1.5 py-[3px] pr-3 cursor-pointer transition-colors hover:bg-card-hover group ${
|
|
140
|
+
isDir ? 'text-foreground' : 'text-muted-foreground'
|
|
141
|
+
}`}
|
|
142
|
+
style={{ paddingLeft: depth * 16 + 12 }}
|
|
143
|
+
onClick={() => isDir && toggleDir(entry.path)}
|
|
144
|
+
>
|
|
145
|
+
{isDir ? (
|
|
146
|
+
<>
|
|
147
|
+
<span className="w-4 text-center text-xs text-muted-foreground flex-shrink-0">
|
|
148
|
+
{isExpanded ? '\u25BC' : '\u25B6'}
|
|
149
|
+
</span>
|
|
150
|
+
<span className="text-sm flex-shrink-0">
|
|
151
|
+
{isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1'}
|
|
152
|
+
</span>
|
|
153
|
+
<span className="text-sm truncate flex-1 font-medium">{entry.name}</span>
|
|
154
|
+
</>
|
|
155
|
+
) : (
|
|
156
|
+
<>
|
|
157
|
+
<span className="w-4 flex-shrink-0" />
|
|
158
|
+
<span className="text-[10px] font-mono w-5 text-center flex-shrink-0 text-muted-foreground/70">
|
|
159
|
+
{getFileIcon(entry.extension)}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="text-sm truncate flex-1">{entry.name}</span>
|
|
162
|
+
{entry.size !== undefined && (
|
|
163
|
+
<span className="text-[10px] text-muted-foreground/50 tabular-nums flex-shrink-0">
|
|
164
|
+
{formatSize(entry.size)}
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
{isDir && isExpanded && renderEntries(entry.path, depth + 1)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const dirName = rootPath.split('/').pop() || rootPath;
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
ref={overlayRef}
|
|
181
|
+
className="fixed inset-0 z-50 flex justify-end"
|
|
182
|
+
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
|
|
183
|
+
>
|
|
184
|
+
{/* Backdrop */}
|
|
185
|
+
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
|
186
|
+
|
|
187
|
+
{/* Drawer */}
|
|
188
|
+
<div className="relative w-[420px] max-w-[85vw] h-full bg-card border-l border-border shadow-2xl flex flex-col animate-drawer-in">
|
|
189
|
+
{/* Header */}
|
|
190
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border flex-shrink-0">
|
|
191
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
192
|
+
<span className="text-base">{'\uD83D\uDCC2'}</span>
|
|
193
|
+
<div className="min-w-0">
|
|
194
|
+
<h2 className="text-sm font-semibold truncate">{dirName}</h2>
|
|
195
|
+
<p className="text-[10px] text-muted-foreground font-mono truncate">{rootPath}</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<button
|
|
199
|
+
onClick={onClose}
|
|
200
|
+
className="text-muted-foreground hover:text-foreground transition-colors text-lg px-1"
|
|
201
|
+
title="Close (ESC)"
|
|
202
|
+
>
|
|
203
|
+
×
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Tree content */}
|
|
208
|
+
<div className="flex-1 overflow-y-auto py-2">
|
|
209
|
+
{renderEntries(rootPath, 0)}
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Footer */}
|
|
213
|
+
<div className="px-4 py-2 border-t border-border flex-shrink-0">
|
|
214
|
+
<p className="text-[10px] text-muted-foreground">
|
|
215
|
+
ESC to close
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -9,6 +9,7 @@ import DirectoryPicker from '@/components/DirectoryPicker';
|
|
|
9
9
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
10
10
|
import AiPolicyModal from '@/components/ui/AiPolicyModal';
|
|
11
11
|
import GitSyncResultsModal from '@/components/dashboard/GitSyncResultsModal';
|
|
12
|
+
import FileTreeDrawer from '@/components/ui/FileTreeDrawer';
|
|
12
13
|
import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats, IGitSyncResult } from '@/types';
|
|
13
14
|
|
|
14
15
|
interface IProject {
|
|
@@ -62,6 +63,7 @@ export default function WorkspacePanel({
|
|
|
62
63
|
const [syncing, setSyncing] = useState(false);
|
|
63
64
|
const [syncResults, setSyncResults] = useState<IGitSyncResult[] | null>(null);
|
|
64
65
|
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
|
66
|
+
const [showFileTree, setShowFileTree] = useState(false);
|
|
65
67
|
const syncingRef = useRef(false);
|
|
66
68
|
|
|
67
69
|
// Resizable panel widths
|
|
@@ -380,9 +382,18 @@ export default function WorkspacePanel({
|
|
|
380
382
|
<span className="text-border">|</span>
|
|
381
383
|
<h1 className="text-sm font-semibold">{project.name}</h1>
|
|
382
384
|
{project.project_path && (
|
|
383
|
-
|
|
384
|
-
{project.project_path}
|
|
385
|
-
|
|
385
|
+
<>
|
|
386
|
+
<span className="text-xs text-muted-foreground font-mono truncate max-w-48" title={project.project_path}>
|
|
387
|
+
{project.project_path}
|
|
388
|
+
</span>
|
|
389
|
+
<button
|
|
390
|
+
onClick={() => setShowFileTree(true)}
|
|
391
|
+
className="text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors px-1.5 py-0.5 rounded"
|
|
392
|
+
title="View file tree"
|
|
393
|
+
>
|
|
394
|
+
{'\uD83D\uDCC2'}
|
|
395
|
+
</button>
|
|
396
|
+
</>
|
|
386
397
|
)}
|
|
387
398
|
</div>
|
|
388
399
|
<div className="flex items-center gap-2">
|
|
@@ -506,6 +517,12 @@ export default function WorkspacePanel({
|
|
|
506
517
|
results={syncResults || []}
|
|
507
518
|
onClose={() => setSyncResults(null)}
|
|
508
519
|
/>
|
|
520
|
+
{showFileTree && project.project_path && (
|
|
521
|
+
<FileTreeDrawer
|
|
522
|
+
rootPath={project.project_path}
|
|
523
|
+
onClose={() => setShowFileTree(false)}
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
509
526
|
</div>
|
|
510
527
|
);
|
|
511
528
|
}
|