idea-manager 0.8.1 → 0.8.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'im-v4';
1
+ const CACHE_NAME = 'im-v5';
2
2
 
3
3
  self.addEventListener('install', (event) => {
4
4
  self.skipWaiting();
@@ -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
+ }
@@ -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
+ }
@@ -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
- <button
104
- onClick={onCreateSub}
105
- className="text-xs text-muted-foreground hover:text-foreground transition-colors"
106
- title="Add sub-project (N)"
107
- >
108
- + <span className="text-muted-foreground/50">N</span>
109
- </button>
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
- onSelectSub(sp.id);
209
- if (isCollapsed) onToggleCollapse(sp.id);
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
+ &times;
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
- <span className="text-xs text-muted-foreground font-mono truncate max-w-48" title={project.project_path}>
384
- {project.project_path}
385
- </span>
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
  }