idea-manager 0.7.8 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -0,0 +1,118 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getProject } from '@/lib/db/queries/projects';
3
+ import { execFile } from 'child_process';
4
+ import { existsSync, readdirSync, statSync } from 'fs';
5
+ import path from 'path';
6
+ import type { IGitSyncResult } from '@/types';
7
+
8
+ function gitPull(cwd: string): Promise<{ stdout: string; stderr: string }> {
9
+ return new Promise((resolve, reject) => {
10
+ execFile('git', ['pull'], { cwd, timeout: 15000 }, (err, stdout, stderr) => {
11
+ if (err) reject(err);
12
+ else resolve({ stdout, stderr });
13
+ });
14
+ });
15
+ }
16
+
17
+ async function syncOneRepo(dirPath: string, name: string): Promise<IGitSyncResult> {
18
+ try {
19
+ const { stdout, stderr } = await gitPull(dirPath);
20
+ const message = (stdout || stderr || '').trim().slice(0, 500);
21
+ return {
22
+ projectId: name,
23
+ projectName: name,
24
+ projectPath: dirPath,
25
+ status: 'success',
26
+ message: message || 'Already up to date.',
27
+ };
28
+ } catch (err: unknown) {
29
+ const message = err instanceof Error ? err.message.slice(0, 500) : 'Unknown error';
30
+ return {
31
+ projectId: name,
32
+ projectName: name,
33
+ projectPath: dirPath,
34
+ status: 'error',
35
+ message,
36
+ };
37
+ }
38
+ }
39
+
40
+ function findGitRepos(rootPath: string): { name: string; path: string }[] {
41
+ // If root itself is a git repo
42
+ if (existsSync(path.join(rootPath, '.git'))) {
43
+ return [{ name: path.basename(rootPath), path: rootPath }];
44
+ }
45
+
46
+ // Otherwise scan immediate subdirectories
47
+ const repos: { name: string; path: string }[] = [];
48
+ try {
49
+ const entries = readdirSync(rootPath);
50
+ for (const entry of entries) {
51
+ if (entry.startsWith('.')) continue;
52
+ const fullPath = path.join(rootPath, entry);
53
+ try {
54
+ if (statSync(fullPath).isDirectory() && existsSync(path.join(fullPath, '.git'))) {
55
+ repos.push({ name: entry, path: fullPath });
56
+ }
57
+ } catch {
58
+ // skip inaccessible dirs
59
+ }
60
+ }
61
+ } catch {
62
+ // skip
63
+ }
64
+ return repos;
65
+ }
66
+
67
+ export async function POST(
68
+ _request: NextRequest,
69
+ { params }: { params: Promise<{ id: string }> },
70
+ ) {
71
+ const { id } = await params;
72
+ const project = getProject(id);
73
+
74
+ if (!project) {
75
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
76
+ }
77
+
78
+ if (!project.project_path) {
79
+ return NextResponse.json([{
80
+ projectId: project.id,
81
+ projectName: project.name,
82
+ projectPath: '',
83
+ status: 'no-path',
84
+ message: 'No folder linked',
85
+ }] satisfies IGitSyncResult[]);
86
+ }
87
+
88
+ const projectPath = project.project_path;
89
+
90
+ if (!existsSync(projectPath)) {
91
+ return NextResponse.json([{
92
+ projectId: project.id,
93
+ projectName: project.name,
94
+ projectPath,
95
+ status: 'error',
96
+ message: 'Directory not found',
97
+ }] satisfies IGitSyncResult[]);
98
+ }
99
+
100
+ const repos = findGitRepos(projectPath);
101
+
102
+ if (repos.length === 0) {
103
+ return NextResponse.json([{
104
+ projectId: project.id,
105
+ projectName: project.name,
106
+ projectPath,
107
+ status: 'no-git',
108
+ message: 'No git repositories found',
109
+ }] satisfies IGitSyncResult[]);
110
+ }
111
+
112
+ const results: IGitSyncResult[] = [];
113
+ for (const repo of repos) {
114
+ results.push(await syncOneRepo(repo.path, repo.name));
115
+ }
116
+
117
+ return NextResponse.json(results);
118
+ }
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import type { IGitSyncResult } from '@/types';
5
+
6
+ const STATUS_STYLE: Record<IGitSyncResult['status'], { icon: string; color: string }> = {
7
+ success: { icon: '\u2705', color: 'text-success' },
8
+ error: { icon: '\u274C', color: 'text-destructive' },
9
+ 'no-git': { icon: '\u2796', color: 'text-muted-foreground' },
10
+ 'no-path': { icon: '\u2796', color: 'text-muted-foreground' },
11
+ };
12
+
13
+ export default function GitSyncResultsModal({
14
+ open,
15
+ results,
16
+ onClose,
17
+ }: {
18
+ open: boolean;
19
+ results: IGitSyncResult[];
20
+ onClose: () => void;
21
+ }) {
22
+ const overlayRef = useRef<HTMLDivElement>(null);
23
+
24
+ useEffect(() => {
25
+ if (!open) return;
26
+ const handler = (e: KeyboardEvent) => {
27
+ if (e.key === 'Escape') onClose();
28
+ };
29
+ window.addEventListener('keydown', handler);
30
+ return () => window.removeEventListener('keydown', handler);
31
+ }, [open, onClose]);
32
+
33
+ if (!open) return null;
34
+
35
+ const successCount = results.filter(r => r.status === 'success').length;
36
+ const errorCount = results.filter(r => r.status === 'error').length;
37
+ const skipCount = results.filter(r => r.status === 'no-git' || r.status === 'no-path').length;
38
+
39
+ return (
40
+ <div
41
+ ref={overlayRef}
42
+ onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
43
+ className="fixed inset-0 z-50 flex items-center justify-center"
44
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(2px)' }}
45
+ >
46
+ <div className="bg-card border border-border rounded-xl shadow-2xl shadow-black/40
47
+ w-full max-w-md mx-4 animate-dialog-in">
48
+ <div className="p-5 border-b border-border">
49
+ <h3 className="text-sm font-semibold text-foreground">Git Sync Results</h3>
50
+ <p className="text-xs text-muted-foreground mt-1">
51
+ {successCount} synced, {errorCount} failed, {skipCount} skipped
52
+ </p>
53
+ </div>
54
+
55
+ <div className="max-h-64 overflow-y-auto p-3 space-y-1.5">
56
+ {results.length === 0 ? (
57
+ <p className="text-xs text-muted-foreground text-center py-4">
58
+ No projects with linked folders
59
+ </p>
60
+ ) : (
61
+ results.map((r) => {
62
+ const style = STATUS_STYLE[r.status];
63
+ return (
64
+ <div key={r.projectId} className="flex items-start gap-2 p-2 rounded-lg bg-muted/50">
65
+ <span className="text-sm flex-shrink-0">{style.icon}</span>
66
+ <div className="flex-1 min-w-0">
67
+ <div className="text-xs font-medium truncate">{r.projectName}</div>
68
+ <div className={`text-xs ${style.color} truncate`} title={r.message}>
69
+ {r.message}
70
+ </div>
71
+ </div>
72
+ </div>
73
+ );
74
+ })
75
+ )}
76
+ </div>
77
+
78
+ <div className="flex justify-end px-5 pb-4 pt-2">
79
+ <button
80
+ onClick={onClose}
81
+ className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground
82
+ bg-muted hover:bg-card-hover border border-border rounded-md transition-colors"
83
+ >
84
+ Close
85
+ </button>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ );
90
+ }
@@ -8,7 +8,8 @@ import TaskDetail from '@/components/task/TaskDetail';
8
8
  import DirectoryPicker from '@/components/DirectoryPicker';
9
9
  import ConfirmDialog from '@/components/ui/ConfirmDialog';
10
10
  import AiPolicyModal from '@/components/ui/AiPolicyModal';
11
- import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats } from '@/types';
11
+ import GitSyncResultsModal from '@/components/dashboard/GitSyncResultsModal';
12
+ import type { ISubProject, ITask, TaskStatus, ISubProjectWithStats, IGitSyncResult } from '@/types';
12
13
 
13
14
  interface IProject {
14
15
  id: string;
@@ -58,6 +59,10 @@ export default function WorkspacePanel({
58
59
  const [showBrainstorm, setShowBrainstorm] = useState(true);
59
60
  const [newSubName, setNewSubName] = useState('');
60
61
  const [showAiPolicy, setShowAiPolicy] = useState(false);
62
+ const [syncing, setSyncing] = useState(false);
63
+ const [syncResults, setSyncResults] = useState<IGitSyncResult[] | null>(null);
64
+ const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
65
+ const syncingRef = useRef(false);
61
66
 
62
67
  // Resizable panel widths
63
68
  const [leftWidth, setLeftWidth] = useState(500);
@@ -279,6 +284,35 @@ export default function WorkspacePanel({
279
284
  }
280
285
  };
281
286
 
287
+ const handleGitSync = useCallback(async (silent = false) => {
288
+ if (syncingRef.current) return;
289
+ syncingRef.current = true;
290
+ if (!silent) setSyncing(true);
291
+ try {
292
+ const res = await fetch(`/api/projects/${id}/git-sync`, { method: 'POST' });
293
+ if (res.ok) {
294
+ const results: IGitSyncResult[] = await res.json();
295
+ setLastSyncTime(new Date());
296
+ if (!silent) {
297
+ setSyncResults(results);
298
+ }
299
+ }
300
+ } catch {
301
+ // silent fail
302
+ } finally {
303
+ syncingRef.current = false;
304
+ if (!silent) setSyncing(false);
305
+ }
306
+ }, [id]);
307
+
308
+ // Auto git-sync every 30 minutes
309
+ useEffect(() => {
310
+ if (!project?.project_path) return;
311
+ const INTERVAL_MS = 30 * 60 * 1000;
312
+ const timer = setInterval(() => handleGitSync(true), INTERVAL_MS);
313
+ return () => clearInterval(timer);
314
+ }, [project?.project_path, handleGitSync]);
315
+
282
316
  const handleToggleWatch = async () => {
283
317
  if (!project) return;
284
318
  const res = await fetch(`/api/projects/${id}`, {
@@ -370,7 +404,24 @@ export default function WorkspacePanel({
370
404
  }`}>
371
405
  AI Policy{project.ai_context ? ' *' : ''}
372
406
  </button>
373
- {!project.project_path && (
407
+ {project.project_path ? (
408
+ <div className="flex items-center gap-1.5">
409
+ <button
410
+ onClick={() => handleGitSync(false)}
411
+ disabled={syncing}
412
+ className="px-3 py-1.5 text-xs bg-muted hover:bg-card-hover text-foreground border border-border rounded-md transition-colors disabled:opacity-50 flex items-center gap-1.5"
413
+ title={lastSyncTime ? `Last sync: ${lastSyncTime.toLocaleTimeString()}` : 'Git pull'}
414
+ >
415
+ <span className={syncing ? 'animate-spin' : ''}>&#x21bb;</span>
416
+ {syncing ? 'Syncing...' : 'Git Sync'}
417
+ </button>
418
+ {lastSyncTime && (
419
+ <span className="text-xs text-muted-foreground">
420
+ {lastSyncTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
421
+ </span>
422
+ )}
423
+ </div>
424
+ ) : (
374
425
  <button onClick={() => setShowDirPicker(true)}
375
426
  className="px-3 py-1.5 text-xs bg-muted hover:bg-card-hover text-foreground border border-border rounded-md transition-colors">
376
427
  Link folder
@@ -450,6 +501,11 @@ export default function WorkspacePanel({
450
501
  onConfirm={handleConfirmAction} onCancel={() => setConfirmAction(null)} />
451
502
  <AiPolicyModal open={showAiPolicy} content={project.ai_context || ''}
452
503
  onSave={handleSaveAiPolicy} onClose={() => setShowAiPolicy(false)} />
504
+ <GitSyncResultsModal
505
+ open={!!syncResults}
506
+ results={syncResults || []}
507
+ onClose={() => setSyncResults(null)}
508
+ />
453
509
  </div>
454
510
  );
455
511
  }
@@ -63,6 +63,14 @@ export interface ITaskConversation {
63
63
  created_at: string;
64
64
  }
65
65
 
66
+ export interface IGitSyncResult {
67
+ projectId: string;
68
+ projectName: string;
69
+ projectPath: string;
70
+ status: 'success' | 'error' | 'no-git' | 'no-path';
71
+ message: string;
72
+ }
73
+
66
74
  export interface ISubProjectWithStats extends ISubProject {
67
75
  task_count: number;
68
76
  active_count: number;