idea-manager 1.6.1 → 1.8.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/.next/build-manifest.json +2 -2
- package/.next/routes-manifest.json +18 -0
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +2 -2
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/archive/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/tree/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/global-memo/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/health/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/apply-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/auto-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/brainstorm/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/git-sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/search/route.js +127 -0
- package/.next/server/app/api/search/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/update/route.js +1 -0
- package/.next/server/app/api/update/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/version/route.js +1 -0
- package/.next/server/app/api/version/route_client-reference-manifest.js +1 -0
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +12 -12
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +11 -8
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +2 -2
- package/.next/static/Fy-Z5gkec2a0fese1C5rW/_buildManifest.js +1 -0
- package/.next/static/chunks/app/_global-error/page-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/archive/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/filesystem/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/filesystem/tree/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/global-memo/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/health/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/search/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/sync/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/update/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/version/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/page-9a1dc101e82c397c.js +28 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/css/eab748b03f49c43a.css +3 -0
- package/package.json +1 -1
- package/src/app/api/search/route.ts +149 -0
- package/src/app/api/update/route.ts +52 -0
- package/src/app/api/version/route.ts +68 -0
- package/src/cli.ts +10 -0
- package/src/components/search/GlobalSearch.tsx +156 -0
- package/src/components/search/QuickCapture.tsx +208 -0
- package/src/components/tabs/TabBar.tsx +2 -0
- package/src/components/tabs/TabShell.tsx +4 -0
- package/src/components/task/CommandPalette.tsx +48 -2
- package/src/components/task/NoteEditor.tsx +16 -1
- package/src/components/task/TaskChat.tsx +31 -20
- package/src/components/task/TaskDetail.tsx +62 -2
- package/src/components/update/UpdateButton.tsx +190 -0
- package/src/components/workspace/WorkspacePanel.tsx +1 -0
- package/src/lib/auto-update.ts +136 -0
- package/.next/static/chunks/app/_global-error/page-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/archive/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/filesystem/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/filesystem/tree/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/global-memo/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/health/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/sync/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/page-6a511af64da7531f.js +0 -28
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-6ec0e723e471f87a.js +0 -1
- package/.next/static/css/cc32379d0efa7d1d.css +0 -3
- package/.next/static/eQXRVHrJt1cKjgp4hKYm8/_buildManifest.js +0 -1
- /package/.next/static/{eQXRVHrJt1cKjgp4hKYm8 → Fy-Z5gkec2a0fese1C5rW}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getDb } from '@/lib/db';
|
|
3
|
+
import { ensureDb } from '@/lib/db';
|
|
4
|
+
|
|
5
|
+
export interface ISearchResult {
|
|
6
|
+
type: 'task' | 'project' | 'sub-project';
|
|
7
|
+
projectId: string;
|
|
8
|
+
projectName: string;
|
|
9
|
+
subProjectId?: string;
|
|
10
|
+
subProjectName?: string;
|
|
11
|
+
taskId?: string;
|
|
12
|
+
title: string;
|
|
13
|
+
snippet?: string;
|
|
14
|
+
status?: string;
|
|
15
|
+
isArchived?: boolean;
|
|
16
|
+
score: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TaskSearchRow {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
project_id: string;
|
|
24
|
+
project_name: string;
|
|
25
|
+
sub_project_id: string;
|
|
26
|
+
sub_project_name: string;
|
|
27
|
+
status: string;
|
|
28
|
+
is_archived: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ProjectSearchRow {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SubProjectSearchRow {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
project_id: string;
|
|
41
|
+
project_name: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeLike(s: string): string {
|
|
45
|
+
return s.replace(/[\\%_]/g, m => '\\' + m);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildSnippet(body: string, query: string): string | undefined {
|
|
49
|
+
if (!body) return undefined;
|
|
50
|
+
const lower = body.toLowerCase();
|
|
51
|
+
const idx = lower.indexOf(query.toLowerCase());
|
|
52
|
+
if (idx < 0) return body.slice(0, 120);
|
|
53
|
+
const start = Math.max(0, idx - 30);
|
|
54
|
+
const end = Math.min(body.length, idx + query.length + 60);
|
|
55
|
+
return (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function scoreMatch(title: string, body: string, query: string): number {
|
|
59
|
+
const q = query.toLowerCase();
|
|
60
|
+
const t = (title ?? '').toLowerCase();
|
|
61
|
+
const b = (body ?? '').toLowerCase();
|
|
62
|
+
let score = 0;
|
|
63
|
+
if (t === q) score += 100;
|
|
64
|
+
else if (t.startsWith(q)) score += 50;
|
|
65
|
+
else if (t.includes(q)) score += 30;
|
|
66
|
+
if (b.includes(q)) score += 10;
|
|
67
|
+
return score;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function GET(request: NextRequest) {
|
|
71
|
+
await ensureDb();
|
|
72
|
+
const { searchParams } = new URL(request.url);
|
|
73
|
+
const raw = (searchParams.get('q') ?? '').trim();
|
|
74
|
+
if (raw.length < 1) return NextResponse.json([]);
|
|
75
|
+
|
|
76
|
+
const q = raw.slice(0, 200);
|
|
77
|
+
const like = `%${escapeLike(q)}%`;
|
|
78
|
+
const db = getDb();
|
|
79
|
+
|
|
80
|
+
const taskRows = db.prepare(`
|
|
81
|
+
SELECT t.id, t.title, t.description, t.status, t.is_archived,
|
|
82
|
+
t.project_id, p.name AS project_name,
|
|
83
|
+
t.sub_project_id, sp.name AS sub_project_name
|
|
84
|
+
FROM tasks t
|
|
85
|
+
JOIN projects p ON p.id = t.project_id
|
|
86
|
+
JOIN sub_projects sp ON sp.id = t.sub_project_id
|
|
87
|
+
WHERE (t.title LIKE ? ESCAPE '\\' OR t.description LIKE ? ESCAPE '\\')
|
|
88
|
+
ORDER BY t.is_archived ASC, t.updated_at DESC
|
|
89
|
+
LIMIT 40
|
|
90
|
+
`).all(like, like) as TaskSearchRow[];
|
|
91
|
+
|
|
92
|
+
const projectRows = db.prepare(`
|
|
93
|
+
SELECT id, name, description FROM projects
|
|
94
|
+
WHERE name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\'
|
|
95
|
+
LIMIT 10
|
|
96
|
+
`).all(like, like) as ProjectSearchRow[];
|
|
97
|
+
|
|
98
|
+
const subProjectRows = db.prepare(`
|
|
99
|
+
SELECT sp.id, sp.name, sp.project_id, p.name AS project_name
|
|
100
|
+
FROM sub_projects sp
|
|
101
|
+
JOIN projects p ON p.id = sp.project_id
|
|
102
|
+
WHERE sp.name LIKE ? ESCAPE '\\'
|
|
103
|
+
LIMIT 10
|
|
104
|
+
`).all(like) as SubProjectSearchRow[];
|
|
105
|
+
|
|
106
|
+
const results: ISearchResult[] = [];
|
|
107
|
+
|
|
108
|
+
for (const r of taskRows) {
|
|
109
|
+
results.push({
|
|
110
|
+
type: 'task',
|
|
111
|
+
projectId: r.project_id,
|
|
112
|
+
projectName: r.project_name,
|
|
113
|
+
subProjectId: r.sub_project_id,
|
|
114
|
+
subProjectName: r.sub_project_name,
|
|
115
|
+
taskId: r.id,
|
|
116
|
+
title: r.title,
|
|
117
|
+
snippet: buildSnippet(r.description, q),
|
|
118
|
+
status: r.status,
|
|
119
|
+
isArchived: r.is_archived === 1,
|
|
120
|
+
score: scoreMatch(r.title, r.description, q) + (r.is_archived ? -5 : 0),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const r of projectRows) {
|
|
125
|
+
results.push({
|
|
126
|
+
type: 'project',
|
|
127
|
+
projectId: r.id,
|
|
128
|
+
projectName: r.name,
|
|
129
|
+
title: r.name,
|
|
130
|
+
snippet: buildSnippet(r.description, q),
|
|
131
|
+
score: scoreMatch(r.name, r.description ?? '', q),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const r of subProjectRows) {
|
|
136
|
+
results.push({
|
|
137
|
+
type: 'sub-project',
|
|
138
|
+
projectId: r.project_id,
|
|
139
|
+
projectName: r.project_name,
|
|
140
|
+
subProjectId: r.id,
|
|
141
|
+
subProjectName: r.name,
|
|
142
|
+
title: r.name,
|
|
143
|
+
score: scoreMatch(r.name, '', q),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
results.sort((a, b) => b.score - a.score);
|
|
148
|
+
return NextResponse.json(results.slice(0, 30));
|
|
149
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
5
|
+
|
|
6
|
+
// Runs `npm install -g idea-manager@latest` as the current user. This only
|
|
7
|
+
// makes sense for local installs of IM — the API is same-origin so there's no
|
|
8
|
+
// remote caller to worry about. After a successful install the running Node
|
|
9
|
+
// process still holds the OLD code; the response tells the client to prompt
|
|
10
|
+
// the user to restart `im start` (or to let PM2 auto-restart).
|
|
11
|
+
export async function POST() {
|
|
12
|
+
const started = Date.now();
|
|
13
|
+
return await new Promise<Response>((resolve) => {
|
|
14
|
+
const child = spawn('npm', ['install', '-g', 'idea-manager@latest', '--no-fund', '--no-audit'], {
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
env: process.env,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
child.stdout?.on('data', (c: Buffer) => { stdout += c.toString(); });
|
|
23
|
+
child.stderr?.on('data', (c: Buffer) => { stderr += c.toString(); });
|
|
24
|
+
|
|
25
|
+
// 3-minute hard timeout — npm install on stale registry can hang otherwise.
|
|
26
|
+
const timeout = setTimeout(() => {
|
|
27
|
+
child.kill('SIGTERM');
|
|
28
|
+
}, 3 * 60 * 1000);
|
|
29
|
+
|
|
30
|
+
child.on('error', (err) => {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
resolve(NextResponse.json({
|
|
33
|
+
ok: false,
|
|
34
|
+
error: err.message,
|
|
35
|
+
durationMs: Date.now() - started,
|
|
36
|
+
}, { status: 500 }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.on('exit', (code, signal) => {
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
const ok = code === 0;
|
|
42
|
+
resolve(NextResponse.json({
|
|
43
|
+
ok,
|
|
44
|
+
code,
|
|
45
|
+
signal: signal ?? null,
|
|
46
|
+
stdout: stdout.slice(-4000),
|
|
47
|
+
stderr: stderr.slice(-4000),
|
|
48
|
+
durationMs: Date.now() - started,
|
|
49
|
+
}, { status: ok ? 200 : 500 }));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
// Read installed version from the package that is actually running.
|
|
8
|
+
// process.cwd() may differ when launched via the CLI, so we resolve
|
|
9
|
+
// relative to this file's runtime location by walking up.
|
|
10
|
+
function readInstalledVersion(): string {
|
|
11
|
+
const candidates = [
|
|
12
|
+
// Running from built standalone output
|
|
13
|
+
join(process.cwd(), 'package.json'),
|
|
14
|
+
// Running from source (dev)
|
|
15
|
+
join(process.cwd(), '..', 'package.json'),
|
|
16
|
+
];
|
|
17
|
+
for (const p of candidates) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(p, 'utf8');
|
|
20
|
+
const parsed = JSON.parse(raw) as { name?: string; version?: string };
|
|
21
|
+
if (parsed.name === 'idea-manager' && typeof parsed.version === 'string') {
|
|
22
|
+
return parsed.version;
|
|
23
|
+
}
|
|
24
|
+
} catch { /* try next */ }
|
|
25
|
+
}
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cmp(a: string, b: string): number {
|
|
30
|
+
const as = a.split('.').map(n => parseInt(n, 10));
|
|
31
|
+
const bs = b.split('.').map(n => parseInt(n, 10));
|
|
32
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
33
|
+
const av = as[i] ?? 0, bv = bs[i] ?? 0;
|
|
34
|
+
if (av !== bv) return av - bv;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CacheEntry { latest: string; at: number }
|
|
40
|
+
let cache: CacheEntry | null = null;
|
|
41
|
+
const CACHE_MS = 10 * 60 * 1000; // 10 minutes
|
|
42
|
+
|
|
43
|
+
async function fetchLatest(): Promise<string | null> {
|
|
44
|
+
if (cache && Date.now() - cache.at < CACHE_MS) return cache.latest;
|
|
45
|
+
try {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
48
|
+
const res = await fetch('https://registry.npmjs.org/idea-manager/latest', {
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
cache: 'no-store',
|
|
51
|
+
});
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const data = await res.json() as { version?: string };
|
|
55
|
+
if (typeof data.version === 'string') {
|
|
56
|
+
cache = { latest: data.version, at: Date.now() };
|
|
57
|
+
return data.version;
|
|
58
|
+
}
|
|
59
|
+
} catch { /* network / timeout */ }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function GET() {
|
|
64
|
+
const current = readInstalledVersion();
|
|
65
|
+
const latest = await fetchLatest();
|
|
66
|
+
const updateAvailable = !!latest && cmp(latest, current) > 0;
|
|
67
|
+
return NextResponse.json({ current, latest, updateAvailable });
|
|
68
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getTaskPrompt } from './lib/db/queries/task-prompts';
|
|
|
10
10
|
import type { McpToolContext } from './lib/mcp/tools';
|
|
11
11
|
import { startWatcher } from './lib/watcher';
|
|
12
12
|
import { syncInit, syncPush, syncPull, syncStatus } from './lib/sync/index';
|
|
13
|
+
import { maybeAutoUpdate, respawnSelf } from './lib/auto-update';
|
|
13
14
|
import { spawn } from 'child_process';
|
|
14
15
|
import path from 'path';
|
|
15
16
|
import { fileURLToPath } from 'url';
|
|
@@ -118,6 +119,15 @@ program
|
|
|
118
119
|
const port = opts.port;
|
|
119
120
|
const fs = await import('fs');
|
|
120
121
|
|
|
122
|
+
// Auto-update on boot — fetches npm latest, installs if newer, respawns.
|
|
123
|
+
// Only runs for `im start`; `im mcp` / `im watch` stay untouched because
|
|
124
|
+
// they may be long-running integrations we shouldn't disrupt.
|
|
125
|
+
const upd = await maybeAutoUpdate(PKG_ROOT);
|
|
126
|
+
if (upd.upgraded) {
|
|
127
|
+
respawnSelf();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
// Resolve next CLI
|
|
122
132
|
let nextCli: string;
|
|
123
133
|
try {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
4
|
+
import { useTabContext } from '@/components/tabs/TabContext';
|
|
5
|
+
import type { ISearchResult } from '@/app/api/search/route';
|
|
6
|
+
|
|
7
|
+
export default function GlobalSearch() {
|
|
8
|
+
const [open, setOpen] = useState(false);
|
|
9
|
+
const [query, setQuery] = useState('');
|
|
10
|
+
const [results, setResults] = useState<ISearchResult[]>([]);
|
|
11
|
+
const [idx, setIdx] = useState(0);
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
14
|
+
const { openProject } = useTabContext();
|
|
15
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
16
|
+
|
|
17
|
+
// Global ⌘P / Ctrl+P shortcut (K is also acceptable in some editors;
|
|
18
|
+
// P is used because ⌘K is the note-refine palette).
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const onKey = (e: KeyboardEvent) => {
|
|
21
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setOpen(prev => !prev);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
window.addEventListener('keydown', onKey);
|
|
27
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
// Reset state each time it opens
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return;
|
|
33
|
+
setQuery('');
|
|
34
|
+
setResults([]);
|
|
35
|
+
setIdx(0);
|
|
36
|
+
// defer focus until after modal animation paint
|
|
37
|
+
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
|
38
|
+
return () => cancelAnimationFrame(id);
|
|
39
|
+
}, [open]);
|
|
40
|
+
|
|
41
|
+
// Debounced search
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
const q = query.trim();
|
|
45
|
+
if (q.length < 1) { setResults([]); setLoading(false); return; }
|
|
46
|
+
setLoading(true);
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
abortRef.current?.abort();
|
|
49
|
+
const abort = new AbortController();
|
|
50
|
+
abortRef.current = abort;
|
|
51
|
+
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: abort.signal })
|
|
52
|
+
.then(r => r.ok ? r.json() : [])
|
|
53
|
+
.then((data: ISearchResult[]) => {
|
|
54
|
+
setResults(Array.isArray(data) ? data : []);
|
|
55
|
+
setIdx(0);
|
|
56
|
+
setLoading(false);
|
|
57
|
+
})
|
|
58
|
+
.catch(() => { /* aborted or network — leave UI steady */ });
|
|
59
|
+
}, 120);
|
|
60
|
+
return () => clearTimeout(timer);
|
|
61
|
+
}, [query, open]);
|
|
62
|
+
|
|
63
|
+
const pick = useCallback((r: ISearchResult) => {
|
|
64
|
+
openProject(r.projectId, r.projectName, r.subProjectId, r.taskId);
|
|
65
|
+
setOpen(false);
|
|
66
|
+
}, [openProject]);
|
|
67
|
+
|
|
68
|
+
const handleKey = (e: React.KeyboardEvent) => {
|
|
69
|
+
if (e.key === 'Escape') { setOpen(false); return; }
|
|
70
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i + 1, results.length - 1)); return; }
|
|
71
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); return; }
|
|
72
|
+
if (e.key === 'Enter') {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
const target = results[idx];
|
|
75
|
+
if (target) pick(target);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!open) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
onClick={() => setOpen(false)}
|
|
84
|
+
className="fixed inset-0 z-[60] flex items-start justify-center pt-[14vh]"
|
|
85
|
+
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(3px)' }}
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
onClick={(e) => e.stopPropagation()}
|
|
89
|
+
className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-xl animate-dialog-in"
|
|
90
|
+
>
|
|
91
|
+
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
|
92
|
+
<span className="text-muted-foreground">🔎</span>
|
|
93
|
+
<input
|
|
94
|
+
ref={inputRef}
|
|
95
|
+
value={query}
|
|
96
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
97
|
+
onKeyDown={handleKey}
|
|
98
|
+
placeholder="태스크 · 프로젝트 · 워크스페이스 검색… (⌘P)"
|
|
99
|
+
className="flex-1 bg-transparent text-sm text-foreground focus:outline-none"
|
|
100
|
+
/>
|
|
101
|
+
<span className="text-[10px] text-muted-foreground/70 px-1.5 py-0.5 border border-border rounded">
|
|
102
|
+
↑↓ · ↵ · Esc
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="max-h-[55vh] overflow-y-auto">
|
|
107
|
+
{loading && (
|
|
108
|
+
<div className="px-4 py-6 text-xs text-muted-foreground">검색 중…</div>
|
|
109
|
+
)}
|
|
110
|
+
{!loading && query.trim() && results.length === 0 && (
|
|
111
|
+
<div className="px-4 py-6 text-xs text-muted-foreground">일치하는 항목 없음</div>
|
|
112
|
+
)}
|
|
113
|
+
{!loading && !query.trim() && (
|
|
114
|
+
<div className="px-4 py-6 text-xs text-muted-foreground">
|
|
115
|
+
무엇을 찾으시나요? 태스크 제목·본문, 프로젝트·워크스페이스 이름을 검색합니다.
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
<ul>
|
|
119
|
+
{results.map((r, i) => (
|
|
120
|
+
<li
|
|
121
|
+
key={`${r.type}-${r.taskId ?? r.subProjectId ?? r.projectId}`}
|
|
122
|
+
onMouseEnter={() => setIdx(i)}
|
|
123
|
+
onClick={() => pick(r)}
|
|
124
|
+
className={`px-4 py-2.5 cursor-pointer border-l-2 ${
|
|
125
|
+
i === idx ? 'bg-muted border-primary' : 'border-transparent'
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
129
|
+
<span className={`px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide ${
|
|
130
|
+
r.type === 'task' ? 'bg-primary/15 text-primary' :
|
|
131
|
+
r.type === 'project' ? 'bg-accent/15 text-accent' :
|
|
132
|
+
'bg-warning/15 text-warning'
|
|
133
|
+
}`}>
|
|
134
|
+
{r.type === 'sub-project' ? 'project' : r.type}
|
|
135
|
+
</span>
|
|
136
|
+
<span>{r.projectName}</span>
|
|
137
|
+
{r.subProjectName && r.type === 'task' && (
|
|
138
|
+
<>
|
|
139
|
+
<span className="opacity-50">›</span>
|
|
140
|
+
<span>{r.subProjectName}</span>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
{r.isArchived && <span className="text-muted-foreground/70 italic">(archived)</span>}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="text-sm text-foreground mt-0.5 truncate">{r.title}</div>
|
|
146
|
+
{r.snippet && (
|
|
147
|
+
<div className="text-xs text-muted-foreground/80 mt-0.5 truncate">{r.snippet}</div>
|
|
148
|
+
)}
|
|
149
|
+
</li>
|
|
150
|
+
))}
|
|
151
|
+
</ul>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useTabContext } from '@/components/tabs/TabContext';
|
|
5
|
+
import type { IProject, ISubProject, ITask } from '@/types';
|
|
6
|
+
|
|
7
|
+
interface ProjectWithSubs extends IProject {
|
|
8
|
+
subProjects?: ISubProject[];
|
|
9
|
+
loaded?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LAST_DEST_KEY = 'im-quick-capture-last-dest';
|
|
13
|
+
|
|
14
|
+
interface LastDest { projectId: string; subProjectId: string }
|
|
15
|
+
|
|
16
|
+
function loadLastDest(): LastDest | null {
|
|
17
|
+
if (typeof window === 'undefined') return null;
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(LAST_DEST_KEY);
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (parsed?.projectId && parsed?.subProjectId) return parsed as LastDest;
|
|
23
|
+
} catch { /* ignore */ }
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveLastDest(dest: LastDest) {
|
|
28
|
+
try { localStorage.setItem(LAST_DEST_KEY, JSON.stringify(dest)); } catch { /* quota */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function QuickCapture() {
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const [projects, setProjects] = useState<ProjectWithSubs[]>([]);
|
|
34
|
+
const [projectId, setProjectId] = useState<string>('');
|
|
35
|
+
const [subProjectId, setSubProjectId] = useState<string>('');
|
|
36
|
+
const [title, setTitle] = useState('');
|
|
37
|
+
const [busy, setBusy] = useState(false);
|
|
38
|
+
const [err, setErr] = useState<string | null>(null);
|
|
39
|
+
const titleRef = useRef<HTMLInputElement>(null);
|
|
40
|
+
const { openProject } = useTabContext();
|
|
41
|
+
|
|
42
|
+
// Global ⌘N / Ctrl+N shortcut
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const onKey = (e: KeyboardEvent) => {
|
|
45
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'n') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setOpen(prev => !prev);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
window.addEventListener('keydown', onKey);
|
|
51
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// Load projects on first open
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open) return;
|
|
57
|
+
fetch('/api/projects')
|
|
58
|
+
.then(r => r.ok ? r.json() : [])
|
|
59
|
+
.then((data: IProject[]) => {
|
|
60
|
+
setProjects(data.map(p => ({ ...p })));
|
|
61
|
+
const last = loadLastDest();
|
|
62
|
+
const fallback = data[0]?.id;
|
|
63
|
+
const initial = last?.projectId && data.some(p => p.id === last.projectId) ? last.projectId : fallback;
|
|
64
|
+
if (initial) setProjectId(initial);
|
|
65
|
+
});
|
|
66
|
+
}, [open]);
|
|
67
|
+
|
|
68
|
+
// Load sub-projects when project changes
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!projectId) { setSubProjectId(''); return; }
|
|
71
|
+
const existing = projects.find(p => p.id === projectId);
|
|
72
|
+
if (existing?.loaded && existing.subProjects) {
|
|
73
|
+
const last = loadLastDest();
|
|
74
|
+
const fallback = existing.subProjects[0]?.id ?? '';
|
|
75
|
+
setSubProjectId(
|
|
76
|
+
last?.projectId === projectId && existing.subProjects.some(s => s.id === last.subProjectId)
|
|
77
|
+
? last.subProjectId
|
|
78
|
+
: fallback,
|
|
79
|
+
);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
fetch(`/api/projects/${projectId}/sub-projects`)
|
|
83
|
+
.then(r => r.ok ? r.json() : [])
|
|
84
|
+
.then((subs: ISubProject[]) => {
|
|
85
|
+
setProjects(prev => prev.map(p =>
|
|
86
|
+
p.id === projectId ? { ...p, subProjects: subs, loaded: true } : p
|
|
87
|
+
));
|
|
88
|
+
const last = loadLastDest();
|
|
89
|
+
const fallback = subs[0]?.id ?? '';
|
|
90
|
+
setSubProjectId(
|
|
91
|
+
last?.projectId === projectId && subs.some(s => s.id === last.subProjectId)
|
|
92
|
+
? last.subProjectId
|
|
93
|
+
: fallback,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
// projects is mutated above; keep deps minimal to avoid refetch loops
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [projectId]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!open) return;
|
|
102
|
+
setTitle('');
|
|
103
|
+
setErr(null);
|
|
104
|
+
setBusy(false);
|
|
105
|
+
const id = requestAnimationFrame(() => titleRef.current?.focus());
|
|
106
|
+
return () => cancelAnimationFrame(id);
|
|
107
|
+
}, [open]);
|
|
108
|
+
|
|
109
|
+
const submit = async () => {
|
|
110
|
+
const t = title.trim();
|
|
111
|
+
if (!t || !projectId || !subProjectId || busy) return;
|
|
112
|
+
setBusy(true);
|
|
113
|
+
setErr(null);
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`/api/projects/${projectId}/sub-projects/${subProjectId}/tasks`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({ title: t }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
121
|
+
const task = await res.json() as ITask;
|
|
122
|
+
saveLastDest({ projectId, subProjectId });
|
|
123
|
+
const project = projects.find(p => p.id === projectId);
|
|
124
|
+
if (project) openProject(project.id, project.name, subProjectId, task.id);
|
|
125
|
+
setOpen(false);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setErr(e instanceof Error ? e.message : '생성 실패');
|
|
128
|
+
setBusy(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!open) return null;
|
|
133
|
+
|
|
134
|
+
const currentProject = projects.find(p => p.id === projectId);
|
|
135
|
+
const subs = currentProject?.subProjects ?? [];
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
onClick={() => setOpen(false)}
|
|
140
|
+
className="fixed inset-0 z-[60] flex items-start justify-center pt-[16vh]"
|
|
141
|
+
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(3px)' }}
|
|
142
|
+
>
|
|
143
|
+
<div
|
|
144
|
+
onClick={(e) => e.stopPropagation()}
|
|
145
|
+
className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md animate-dialog-in p-4 flex flex-col gap-3"
|
|
146
|
+
>
|
|
147
|
+
<div className="flex items-center justify-between">
|
|
148
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">빠른 태스크 캡처</div>
|
|
149
|
+
<span className="text-[10px] text-muted-foreground/70 px-1.5 py-0.5 border border-border rounded">⌘N · Esc</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="grid grid-cols-2 gap-2">
|
|
153
|
+
<select
|
|
154
|
+
value={projectId}
|
|
155
|
+
onChange={(e) => setProjectId(e.target.value)}
|
|
156
|
+
className="bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
|
157
|
+
>
|
|
158
|
+
{projects.map(p => (
|
|
159
|
+
<option key={p.id} value={p.id}>{p.name}</option>
|
|
160
|
+
))}
|
|
161
|
+
</select>
|
|
162
|
+
<select
|
|
163
|
+
value={subProjectId}
|
|
164
|
+
onChange={(e) => setSubProjectId(e.target.value)}
|
|
165
|
+
disabled={!subs.length}
|
|
166
|
+
className="bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none disabled:opacity-50"
|
|
167
|
+
>
|
|
168
|
+
{subs.length === 0 ? (
|
|
169
|
+
<option value="">프로젝트 없음</option>
|
|
170
|
+
) : subs.map(s => (
|
|
171
|
+
<option key={s.id} value={s.id}>{s.name}</option>
|
|
172
|
+
))}
|
|
173
|
+
</select>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<input
|
|
177
|
+
ref={titleRef}
|
|
178
|
+
value={title}
|
|
179
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
180
|
+
onKeyDown={(e) => {
|
|
181
|
+
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
|
182
|
+
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
|
|
183
|
+
}}
|
|
184
|
+
placeholder="태스크 제목을 입력하고 Enter…"
|
|
185
|
+
className="w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
{err && <div className="text-xs text-destructive">⚠ {err}</div>}
|
|
189
|
+
|
|
190
|
+
<div className="flex items-center justify-between">
|
|
191
|
+
<div className="text-[10px] text-muted-foreground/70">
|
|
192
|
+
저장 후 해당 태스크 워크스페이스로 바로 이동
|
|
193
|
+
</div>
|
|
194
|
+
<div className="flex gap-2">
|
|
195
|
+
<button onClick={() => setOpen(false)} className="text-xs text-muted-foreground px-2 py-1">취소</button>
|
|
196
|
+
<button
|
|
197
|
+
onClick={submit}
|
|
198
|
+
disabled={!title.trim() || !projectId || !subProjectId || busy}
|
|
199
|
+
className="text-xs px-3 py-1 bg-primary text-primary-foreground rounded disabled:opacity-40"
|
|
200
|
+
>
|
|
201
|
+
{busy ? '…' : '생성'}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTabContext } from './TabContext';
|
|
4
4
|
import ThemePicker from '@/components/theme/ThemePicker';
|
|
5
|
+
import UpdateButton from '@/components/update/UpdateButton';
|
|
5
6
|
|
|
6
7
|
export default function TabBar() {
|
|
7
8
|
const { state, setActiveTab, closeTab } = useTabContext();
|
|
@@ -43,6 +44,7 @@ export default function TabBar() {
|
|
|
43
44
|
);
|
|
44
45
|
})}
|
|
45
46
|
<div className="tab-bar-spacer" />
|
|
47
|
+
<UpdateButton />
|
|
46
48
|
<ThemePicker />
|
|
47
49
|
</div>
|
|
48
50
|
);
|
|
@@ -4,6 +4,8 @@ import { useTabContext } from './TabContext';
|
|
|
4
4
|
import TabBar from './TabBar';
|
|
5
5
|
import DashboardPanel from '@/components/dashboard/DashboardPanel';
|
|
6
6
|
import WorkspacePanel from '@/components/workspace/WorkspacePanel';
|
|
7
|
+
import GlobalSearch from '@/components/search/GlobalSearch';
|
|
8
|
+
import QuickCapture from '@/components/search/QuickCapture';
|
|
7
9
|
|
|
8
10
|
export default function TabShell() {
|
|
9
11
|
const { state } = useTabContext();
|
|
@@ -11,6 +13,8 @@ export default function TabShell() {
|
|
|
11
13
|
return (
|
|
12
14
|
<div className="h-screen flex flex-col">
|
|
13
15
|
<TabBar />
|
|
16
|
+
<GlobalSearch />
|
|
17
|
+
<QuickCapture />
|
|
14
18
|
<div className="flex-1 min-h-0 relative">
|
|
15
19
|
{state.tabs.map((tab) => (
|
|
16
20
|
<div
|