create-interview-cockpit 0.17.3 → 0.18.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/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +83 -8
- package/template/client/src/components/GithubActionsLabModal.tsx +746 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +400 -14
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +287 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +83 -10
- package/template/client/src/types.ts +27 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +468 -0
- package/template/server/src/google-drive.ts +35 -24
- package/template/server/src/index.ts +241 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
|
-
import { parseInfraLabWorkspace } from "../infraLab";
|
|
3
|
+
import { DOCKER_DEEP_DIVE_LAB, parseInfraLabWorkspace } from "../infraLab";
|
|
4
|
+
import { DEFAULT_GHA_LAB, parseGhaLabWorkspace } from "../githubActionsLab";
|
|
5
|
+
import { ENTERPRISE_LOCAL_AUTH_LAB } from "../enterpriseLocalLab";
|
|
4
6
|
import {
|
|
5
7
|
parseFrontendLabWorkspace,
|
|
6
8
|
ISOLATED_MODULE_FEDERATION_LAB,
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
NEXTJS_MF_RUNTIME_LAB,
|
|
9
11
|
NEXTJS_MULTI_ZONES_LAB,
|
|
10
12
|
NEXTJS_MF_RUNTIME_API_LAB,
|
|
13
|
+
NEXTJS_BFF_AUTH_CLIENT_LAB,
|
|
11
14
|
RSPACK_SHELL_LAB,
|
|
12
15
|
} from "../reactLab";
|
|
13
16
|
import { BROWSER_SECURITY_TEMPLATES } from "../browserSecurityTemplates";
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
Network,
|
|
30
33
|
Shield,
|
|
31
34
|
PenLine,
|
|
35
|
+
GitBranch,
|
|
32
36
|
} from "lucide-react";
|
|
33
37
|
|
|
34
38
|
// ─── Helpers ─────────────────────────────────────────────
|
|
@@ -41,6 +45,7 @@ const LAB_ORIGINS = new Set([
|
|
|
41
45
|
"nextjs",
|
|
42
46
|
"module-federation",
|
|
43
47
|
"canvas",
|
|
48
|
+
"github-actions",
|
|
44
49
|
]);
|
|
45
50
|
|
|
46
51
|
function isLabFile(cf: ContextFile) {
|
|
@@ -196,6 +201,7 @@ export default function LabsPanel() {
|
|
|
196
201
|
currentQuestion,
|
|
197
202
|
openSandbox,
|
|
198
203
|
openInfraLab,
|
|
204
|
+
openGhaLab,
|
|
199
205
|
openReactLab,
|
|
200
206
|
openNextLab,
|
|
201
207
|
openModuleFederationLab,
|
|
@@ -302,6 +308,18 @@ export default function LabsPanel() {
|
|
|
302
308
|
if (parsed) openInfraLab(parsed, cf.id);
|
|
303
309
|
};
|
|
304
310
|
|
|
311
|
+
const openGhaFile = async (cf: ContextFile) => {
|
|
312
|
+
try {
|
|
313
|
+
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
314
|
+
.then((r) => r.json())
|
|
315
|
+
.then((d) => d.content as string);
|
|
316
|
+
const parsed = parseGhaLabWorkspace(raw);
|
|
317
|
+
if (parsed) openGhaLab(parsed, cf.id);
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
305
323
|
const openReactFile = async (cf: ContextFile) => {
|
|
306
324
|
try {
|
|
307
325
|
const raw = await fetch(`/api/context-files/${cf.id}/content`)
|
|
@@ -606,13 +624,50 @@ export default function LabsPanel() {
|
|
|
606
624
|
iconColor="text-cyan-400/70"
|
|
607
625
|
origin="infra"
|
|
608
626
|
emptyText="Save an infra lab to reopen it here"
|
|
609
|
-
|
|
610
|
-
|
|
627
|
+
newLabMenu={[
|
|
628
|
+
{
|
|
629
|
+
label: "AWS LocalStack S3",
|
|
630
|
+
description:
|
|
631
|
+
"Terraform AWS provider pointed at local emulation",
|
|
632
|
+
onClick: () => openInfraLab(),
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
label: "Docker Deep Dive",
|
|
636
|
+
description:
|
|
637
|
+
"Dockerfile + Compose + Node API + Redis, with command-line practice",
|
|
638
|
+
onClick: () => openInfraLab(DOCKER_DEEP_DIVE_LAB),
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
label: "Enterprise BFF Docker Stack",
|
|
642
|
+
description:
|
|
643
|
+
"Terraform deploys NestJS BFF, OIDC mock, Redis & claims API",
|
|
644
|
+
onClick: () => openInfraLab(ENTERPRISE_LOCAL_AUTH_LAB),
|
|
645
|
+
},
|
|
646
|
+
]}
|
|
611
647
|
onOpen={openInfraFile}
|
|
612
648
|
openTitle="Open in Infrastructure Lab"
|
|
613
649
|
accentClass="text-cyan-200"
|
|
614
650
|
bgClass="bg-cyan-500/10 border border-cyan-500/20"
|
|
615
651
|
/>
|
|
652
|
+
<Section
|
|
653
|
+
title="GitHub Actions"
|
|
654
|
+
icon={GitBranch}
|
|
655
|
+
iconColor="text-amber-400/70"
|
|
656
|
+
origin="github-actions"
|
|
657
|
+
emptyText="Save a GitHub Actions lab to reopen it here"
|
|
658
|
+
newLabMenu={[
|
|
659
|
+
{
|
|
660
|
+
label: "Workflows + Composite Action",
|
|
661
|
+
description:
|
|
662
|
+
"Multi-job CI workflow with a local composite action and a matrix build",
|
|
663
|
+
onClick: () => openGhaLab(DEFAULT_GHA_LAB),
|
|
664
|
+
},
|
|
665
|
+
]}
|
|
666
|
+
onOpen={openGhaFile}
|
|
667
|
+
openTitle="Open in GitHub Actions Lab"
|
|
668
|
+
accentClass="text-amber-200"
|
|
669
|
+
bgClass="bg-amber-500/10 border border-amber-500/20"
|
|
670
|
+
/>
|
|
616
671
|
<Section
|
|
617
672
|
title="React Labs"
|
|
618
673
|
icon={Atom}
|
|
@@ -632,8 +687,19 @@ export default function LabsPanel() {
|
|
|
632
687
|
iconColor="text-violet-400/70"
|
|
633
688
|
origin="nextjs"
|
|
634
689
|
emptyText="Save a Next.js lab to reopen it here"
|
|
635
|
-
|
|
636
|
-
|
|
690
|
+
newLabMenu={[
|
|
691
|
+
{
|
|
692
|
+
label: "Blank App Router",
|
|
693
|
+
description: "Standard Next.js App Router practice lab",
|
|
694
|
+
onClick: () => openNextLab(),
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
label: "BFF Auth Client",
|
|
698
|
+
description:
|
|
699
|
+
"Next.js shell that signs in through the local Terraform-deployed BFF",
|
|
700
|
+
onClick: () => openNextLab(NEXTJS_BFF_AUTH_CLIENT_LAB),
|
|
701
|
+
},
|
|
702
|
+
]}
|
|
637
703
|
onOpen={openNextFile}
|
|
638
704
|
openTitle="Open in Next.js Lab"
|
|
639
705
|
accentClass="text-violet-200"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, Fragment } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import type { Question } from "../types";
|
|
4
|
+
import * as api from "../api";
|
|
4
5
|
import FileAttachments from "./FileAttachments";
|
|
5
6
|
import WorkspaceSwitcher from "./WorkspaceSwitcher";
|
|
6
7
|
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
MoreHorizontal,
|
|
24
25
|
Download,
|
|
25
26
|
Link,
|
|
27
|
+
Upload,
|
|
28
|
+
Copy,
|
|
26
29
|
} from "lucide-react";
|
|
27
30
|
|
|
28
31
|
const ROOT_PARENT_VALUE = "__root__";
|
|
@@ -41,20 +44,46 @@ function downloadJson(data: unknown, filename: string) {
|
|
|
41
44
|
URL.revokeObjectURL(url);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
47
|
+
async function serializeQuestionContextFiles(q: Question) {
|
|
48
|
+
return Promise.all(
|
|
49
|
+
(q.contextFiles || []).map(async (cf) => {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`/api/context-files/${cf.id}/content`, {
|
|
52
|
+
cache: "no-store",
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) return cf;
|
|
55
|
+
const body = (await res.json()) as { content?: string };
|
|
56
|
+
return typeof body.content === "string"
|
|
57
|
+
? { ...cf, content: body.content }
|
|
58
|
+
: cf;
|
|
59
|
+
} catch {
|
|
60
|
+
return cf;
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function buildQuestionExport(
|
|
67
|
+
q: Question,
|
|
68
|
+
allQuestions: Question[],
|
|
69
|
+
): Promise<object> {
|
|
70
|
+
const children = await Promise.all(
|
|
71
|
+
allQuestions
|
|
72
|
+
.filter((c) => c.parentQuestionId === q.id)
|
|
73
|
+
.map((c) => buildQuestionExport(c, allQuestions)),
|
|
74
|
+
);
|
|
48
75
|
return {
|
|
49
76
|
id: q.id,
|
|
50
77
|
title: q.title,
|
|
51
78
|
topicId: q.topicId,
|
|
52
79
|
parentQuestionId: q.parentQuestionId ?? null,
|
|
53
80
|
systemContext: q.systemContext,
|
|
54
|
-
contextFiles: q
|
|
81
|
+
contextFiles: await serializeQuestionContextFiles(q),
|
|
55
82
|
codeContextFiles: q.codeContextFiles,
|
|
56
83
|
messages: q.messages,
|
|
57
84
|
annotations: q.annotations ?? [],
|
|
85
|
+
readingBookmark: q.readingBookmark,
|
|
86
|
+
codeAnnotations: q.codeAnnotations ?? {},
|
|
58
87
|
linkedConversationIds: q.linkedConversationIds ?? [],
|
|
59
88
|
createdAt: q.createdAt,
|
|
60
89
|
...(children.length > 0 ? { children } : {}),
|
|
@@ -74,6 +103,7 @@ export default function Sidebar() {
|
|
|
74
103
|
addQuestion,
|
|
75
104
|
addChildQuestion,
|
|
76
105
|
moveQuestion,
|
|
106
|
+
copyQuestion,
|
|
77
107
|
removeQuestion,
|
|
78
108
|
renameQuestion,
|
|
79
109
|
selectQuestion,
|
|
@@ -89,6 +119,7 @@ export default function Sidebar() {
|
|
|
89
119
|
selectDriveSubfolder,
|
|
90
120
|
clearDriveSubfolder,
|
|
91
121
|
syncWorkspace,
|
|
122
|
+
exportWorkspace,
|
|
92
123
|
workspaceFiles,
|
|
93
124
|
uploadWorkspaceFiles,
|
|
94
125
|
removeWorkspaceFile,
|
|
@@ -113,6 +144,18 @@ export default function Sidebar() {
|
|
|
113
144
|
const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
|
|
114
145
|
const [moveTargetParentId, setMoveTargetParentId] =
|
|
115
146
|
useState(ROOT_PARENT_VALUE);
|
|
147
|
+
const [copyingQuestionId, setCopyingQuestionId] = useState<string | null>(
|
|
148
|
+
null,
|
|
149
|
+
);
|
|
150
|
+
const [copyTargetParentId, setCopyTargetParentId] =
|
|
151
|
+
useState(ROOT_PARENT_VALUE);
|
|
152
|
+
const [copyingToTopicQuestionId, setCopyingToTopicQuestionId] = useState<
|
|
153
|
+
string | null
|
|
154
|
+
>(null);
|
|
155
|
+
const [copyTargetTopicId, setCopyTargetTopicId] = useState("");
|
|
156
|
+
const [copyTargetTopicParentId, setCopyTargetTopicParentId] =
|
|
157
|
+
useState(ROOT_PARENT_VALUE);
|
|
158
|
+
const [copyingNowId, setCopyingNowId] = useState<string | null>(null);
|
|
116
159
|
const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
|
|
117
160
|
new Set(),
|
|
118
161
|
);
|
|
@@ -165,9 +208,18 @@ export default function Sidebar() {
|
|
|
165
208
|
name: activeWs.driveConfig.subFolderName ?? "",
|
|
166
209
|
}
|
|
167
210
|
: null;
|
|
211
|
+
const isAtDriveFolderRoot = isDriveWs && !currentSubFolder;
|
|
212
|
+
const canSyncDriveFolder =
|
|
213
|
+
!!activeWs?.driveConfig?.folderId && !isAtDriveFolderRoot;
|
|
214
|
+
const driveSyncTargetName =
|
|
215
|
+
currentSubFolder?.name ?? activeWs?.driveConfig?.folderName ?? "Drive";
|
|
168
216
|
const [navigating, setNavigating] = useState(false);
|
|
169
217
|
const [syncing, setSyncing] = useState(false);
|
|
218
|
+
const [pushing, setPushing] = useState(false);
|
|
170
219
|
const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
|
|
220
|
+
const [driveFileSyncStatus, setDriveFileSyncStatus] = useState<string | null>(
|
|
221
|
+
null,
|
|
222
|
+
);
|
|
171
223
|
|
|
172
224
|
// Load root folders whenever a Drive workspace becomes active with no subfolder selected
|
|
173
225
|
useEffect(() => {
|
|
@@ -197,13 +249,51 @@ export default function Sidebar() {
|
|
|
197
249
|
|
|
198
250
|
const handleResync = async () => {
|
|
199
251
|
setSyncing(true);
|
|
252
|
+
setDriveFileSyncStatus(null);
|
|
200
253
|
try {
|
|
201
|
-
await syncWorkspace(activeWorkspaceId!);
|
|
254
|
+
const result = await syncWorkspace(activeWorkspaceId!);
|
|
255
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
256
|
+
window.location.href = result.authUrl;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const firstError = result.errors[0];
|
|
260
|
+
setDriveFileSyncStatus(
|
|
261
|
+
result.errors.length > 0
|
|
262
|
+
? `Pull finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}. ${firstError ? `First: ${firstError}` : ""}`
|
|
263
|
+
: `Pulled ${result.filesImported} file${result.filesImported === 1 ? "" : "s"} from Drive.`,
|
|
264
|
+
);
|
|
265
|
+
} catch (err: any) {
|
|
266
|
+
setDriveFileSyncStatus(err?.message || "Pull from Drive failed.");
|
|
202
267
|
} finally {
|
|
203
268
|
setSyncing(false);
|
|
204
269
|
}
|
|
205
270
|
};
|
|
206
271
|
|
|
272
|
+
const handlePushToDrive = async () => {
|
|
273
|
+
if (!activeWorkspaceId || !activeWs?.driveConfig?.folderId) return;
|
|
274
|
+
setPushing(true);
|
|
275
|
+
setDriveFileSyncStatus(null);
|
|
276
|
+
try {
|
|
277
|
+
const result = await exportWorkspace(
|
|
278
|
+
activeWorkspaceId,
|
|
279
|
+
activeWs.driveConfig.subFolderId,
|
|
280
|
+
);
|
|
281
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
282
|
+
window.location.href = result.authUrl;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
setDriveFileSyncStatus(
|
|
286
|
+
result.errors.length > 0
|
|
287
|
+
? `Push finished with ${result.errors.length} error${result.errors.length === 1 ? "" : "s"}.`
|
|
288
|
+
: `Pushed ${result.filesExported} file${result.filesExported === 1 ? "" : "s"} to Drive.`,
|
|
289
|
+
);
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
setDriveFileSyncStatus(err?.message || "Push to Drive failed.");
|
|
292
|
+
} finally {
|
|
293
|
+
setPushing(false);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
207
297
|
useEffect(() => {
|
|
208
298
|
if (editingTopicId || editingQuestionId) {
|
|
209
299
|
editInputRef.current?.select();
|
|
@@ -296,6 +386,37 @@ export default function Sidebar() {
|
|
|
296
386
|
setMovingQuestionId(null);
|
|
297
387
|
};
|
|
298
388
|
|
|
389
|
+
const handleCopyQuestion = async (
|
|
390
|
+
topicId: string,
|
|
391
|
+
questionId: string,
|
|
392
|
+
targetParentId: string | null,
|
|
393
|
+
targetTopicId = topicId,
|
|
394
|
+
) => {
|
|
395
|
+
setCopyingNowId(questionId);
|
|
396
|
+
try {
|
|
397
|
+
await copyQuestion(questionId, topicId, targetParentId, targetTopicId);
|
|
398
|
+
setCopyingQuestionId(null);
|
|
399
|
+
setCopyingToTopicQuestionId(null);
|
|
400
|
+
setCopyTargetParentId(ROOT_PARENT_VALUE);
|
|
401
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
402
|
+
} finally {
|
|
403
|
+
setCopyingNowId(null);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const openCopyToTopicPicker = (q: Question, topicId: string) => {
|
|
408
|
+
const defaultTargetTopicId =
|
|
409
|
+
topics.find((topic) => topic.id !== topicId)?.id ?? "";
|
|
410
|
+
setMovingQuestionId(null);
|
|
411
|
+
setCopyingQuestionId(null);
|
|
412
|
+
setCopyingToTopicQuestionId((prev) => (prev === q.id ? null : q.id));
|
|
413
|
+
setCopyTargetTopicId(defaultTargetTopicId);
|
|
414
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
415
|
+
if (defaultTargetTopicId && !questionsByTopic[defaultTargetTopicId]) {
|
|
416
|
+
void fetchQuestions(defaultTargetTopicId);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
299
420
|
const renderMoveQuestionPicker = (
|
|
300
421
|
questions: Question[],
|
|
301
422
|
q: Question,
|
|
@@ -357,6 +478,179 @@ export default function Sidebar() {
|
|
|
357
478
|
);
|
|
358
479
|
};
|
|
359
480
|
|
|
481
|
+
const renderCopyQuestionPicker = (
|
|
482
|
+
questions: Question[],
|
|
483
|
+
q: Question,
|
|
484
|
+
topicId: string,
|
|
485
|
+
depth: number,
|
|
486
|
+
) => {
|
|
487
|
+
if (copyingQuestionId !== q.id) return null;
|
|
488
|
+
const parentOptions = buildMoveParentOptions(questions, new Set(), null, 0);
|
|
489
|
+
const targetParentId =
|
|
490
|
+
copyTargetParentId === ROOT_PARENT_VALUE ? null : copyTargetParentId;
|
|
491
|
+
const isCopying = copyingNowId === q.id;
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<div
|
|
495
|
+
className="pr-2 py-1.5 animate-fadeIn"
|
|
496
|
+
style={{ paddingLeft: 12 + (depth + 1) * 16 }}
|
|
497
|
+
>
|
|
498
|
+
<div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
|
|
499
|
+
<div>
|
|
500
|
+
<div className="text-[11px] text-slate-500">Copy under</div>
|
|
501
|
+
<p className="mt-0.5 text-[10px] leading-snug text-slate-600">
|
|
502
|
+
Duplicates this question, its children, messages, and attached
|
|
503
|
+
files.
|
|
504
|
+
</p>
|
|
505
|
+
</div>
|
|
506
|
+
<select
|
|
507
|
+
autoFocus
|
|
508
|
+
value={copyTargetParentId}
|
|
509
|
+
onChange={(e) => setCopyTargetParentId(e.target.value)}
|
|
510
|
+
disabled={isCopying}
|
|
511
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
512
|
+
>
|
|
513
|
+
<option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
|
|
514
|
+
{parentOptions.map((option) => (
|
|
515
|
+
<option key={option.id} value={option.id}>
|
|
516
|
+
{option.title}
|
|
517
|
+
</option>
|
|
518
|
+
))}
|
|
519
|
+
</select>
|
|
520
|
+
<div className="flex items-center justify-end gap-2">
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={() => setCopyingQuestionId(null)}
|
|
524
|
+
disabled={isCopying}
|
|
525
|
+
className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
|
526
|
+
>
|
|
527
|
+
Cancel
|
|
528
|
+
</button>
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
onClick={() => handleCopyQuestion(topicId, q.id, targetParentId)}
|
|
532
|
+
disabled={isCopying}
|
|
533
|
+
className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors inline-flex items-center gap-1.5"
|
|
534
|
+
>
|
|
535
|
+
{isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
536
|
+
Copy
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const renderCopyToTopicPicker = (
|
|
545
|
+
q: Question,
|
|
546
|
+
sourceTopicId: string,
|
|
547
|
+
depth: number,
|
|
548
|
+
) => {
|
|
549
|
+
if (copyingToTopicQuestionId !== q.id) return null;
|
|
550
|
+
const destinationTopics = topics.filter(
|
|
551
|
+
(topic) => topic.id !== sourceTopicId,
|
|
552
|
+
);
|
|
553
|
+
const selectedTopicId = copyTargetTopicId || destinationTopics[0]?.id || "";
|
|
554
|
+
const targetQuestions = selectedTopicId
|
|
555
|
+
? (questionsByTopic[selectedTopicId] ?? [])
|
|
556
|
+
: [];
|
|
557
|
+
const parentOptions = buildMoveParentOptions(
|
|
558
|
+
targetQuestions,
|
|
559
|
+
new Set(),
|
|
560
|
+
null,
|
|
561
|
+
0,
|
|
562
|
+
);
|
|
563
|
+
const targetParentId =
|
|
564
|
+
copyTargetTopicParentId === ROOT_PARENT_VALUE
|
|
565
|
+
? null
|
|
566
|
+
: copyTargetTopicParentId;
|
|
567
|
+
const isCopying = copyingNowId === q.id;
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<div
|
|
571
|
+
className="pr-2 py-1.5 animate-fadeIn"
|
|
572
|
+
style={{ paddingLeft: 12 + (depth + 1) * 16 }}
|
|
573
|
+
>
|
|
574
|
+
<div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
|
|
575
|
+
<div>
|
|
576
|
+
<div className="text-[11px] text-slate-500">Copy to topic</div>
|
|
577
|
+
<p className="mt-0.5 text-[10px] leading-snug text-slate-600">
|
|
578
|
+
Creates an independent copy in another topic, including children,
|
|
579
|
+
messages, and attached files.
|
|
580
|
+
</p>
|
|
581
|
+
</div>
|
|
582
|
+
{destinationTopics.length > 0 ? (
|
|
583
|
+
<>
|
|
584
|
+
<select
|
|
585
|
+
autoFocus
|
|
586
|
+
value={selectedTopicId}
|
|
587
|
+
onChange={(e) => {
|
|
588
|
+
const nextTopicId = e.target.value;
|
|
589
|
+
setCopyTargetTopicId(nextTopicId);
|
|
590
|
+
setCopyTargetTopicParentId(ROOT_PARENT_VALUE);
|
|
591
|
+
if (nextTopicId && !questionsByTopic[nextTopicId]) {
|
|
592
|
+
void fetchQuestions(nextTopicId);
|
|
593
|
+
}
|
|
594
|
+
}}
|
|
595
|
+
disabled={isCopying}
|
|
596
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
597
|
+
>
|
|
598
|
+
{destinationTopics.map((topic) => (
|
|
599
|
+
<option key={topic.id} value={topic.id}>
|
|
600
|
+
{topic.name}
|
|
601
|
+
</option>
|
|
602
|
+
))}
|
|
603
|
+
</select>
|
|
604
|
+
<select
|
|
605
|
+
value={copyTargetTopicParentId}
|
|
606
|
+
onChange={(e) => setCopyTargetTopicParentId(e.target.value)}
|
|
607
|
+
disabled={isCopying || !selectedTopicId}
|
|
608
|
+
className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500 disabled:opacity-60"
|
|
609
|
+
>
|
|
610
|
+
<option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
|
|
611
|
+
{parentOptions.map((option) => (
|
|
612
|
+
<option key={option.id} value={option.id}>
|
|
613
|
+
{option.title}
|
|
614
|
+
</option>
|
|
615
|
+
))}
|
|
616
|
+
</select>
|
|
617
|
+
</>
|
|
618
|
+
) : (
|
|
619
|
+
<p className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1.5 text-[11px] text-slate-500">
|
|
620
|
+
Create another topic first, then copy this question into it.
|
|
621
|
+
</p>
|
|
622
|
+
)}
|
|
623
|
+
<div className="flex items-center justify-end gap-2">
|
|
624
|
+
<button
|
|
625
|
+
type="button"
|
|
626
|
+
onClick={() => setCopyingToTopicQuestionId(null)}
|
|
627
|
+
disabled={isCopying}
|
|
628
|
+
className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
|
629
|
+
>
|
|
630
|
+
Cancel
|
|
631
|
+
</button>
|
|
632
|
+
<button
|
|
633
|
+
type="button"
|
|
634
|
+
onClick={() =>
|
|
635
|
+
handleCopyQuestion(
|
|
636
|
+
sourceTopicId,
|
|
637
|
+
q.id,
|
|
638
|
+
targetParentId,
|
|
639
|
+
selectedTopicId,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
disabled={isCopying || !selectedTopicId}
|
|
643
|
+
className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors inline-flex items-center gap-1.5"
|
|
644
|
+
>
|
|
645
|
+
{isCopying && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
646
|
+
Copy
|
|
647
|
+
</button>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
};
|
|
653
|
+
|
|
360
654
|
const renderQuestionRow = (
|
|
361
655
|
q: Question,
|
|
362
656
|
topicId: string,
|
|
@@ -491,6 +785,8 @@ export default function Sidebar() {
|
|
|
491
785
|
<button
|
|
492
786
|
onClick={() => {
|
|
493
787
|
setOpenMenuQuestionId(null);
|
|
788
|
+
setCopyingQuestionId(null);
|
|
789
|
+
setCopyingToTopicQuestionId(null);
|
|
494
790
|
setMovingQuestionId((prev) =>
|
|
495
791
|
prev === q.id ? null : q.id,
|
|
496
792
|
);
|
|
@@ -502,13 +798,45 @@ export default function Sidebar() {
|
|
|
502
798
|
>
|
|
503
799
|
<ArrowRightLeft className="w-3 h-3" /> Move
|
|
504
800
|
</button>
|
|
505
|
-
<div className="border-t border-slate-700 my-0.5" />
|
|
506
801
|
<button
|
|
507
802
|
onClick={() => {
|
|
508
803
|
setOpenMenuQuestionId(null);
|
|
509
|
-
|
|
804
|
+
setMovingQuestionId(null);
|
|
805
|
+
setCopyingToTopicQuestionId(null);
|
|
806
|
+
setCopyingQuestionId((prev) =>
|
|
807
|
+
prev === q.id ? null : q.id,
|
|
808
|
+
);
|
|
809
|
+
setCopyTargetParentId(
|
|
810
|
+
q.parentQuestionId ?? ROOT_PARENT_VALUE,
|
|
811
|
+
);
|
|
812
|
+
}}
|
|
813
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
814
|
+
>
|
|
815
|
+
<Copy className="w-3 h-3" /> Copy in topic
|
|
816
|
+
</button>
|
|
817
|
+
<button
|
|
818
|
+
onClick={() => {
|
|
819
|
+
setOpenMenuQuestionId(null);
|
|
820
|
+
openCopyToTopicPicker(q, topicId);
|
|
821
|
+
}}
|
|
822
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
823
|
+
>
|
|
824
|
+
<Copy className="w-3 h-3" /> Copy to topic
|
|
825
|
+
</button>
|
|
826
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
827
|
+
<button
|
|
828
|
+
onClick={async () => {
|
|
829
|
+
setOpenMenuQuestionId(null);
|
|
830
|
+
let allQ = questionsByTopic[topicId] ?? [];
|
|
831
|
+
try {
|
|
832
|
+
allQ = await api.fetchQuestions(topicId);
|
|
833
|
+
} catch {
|
|
834
|
+
// Fall back to the already-loaded sidebar snapshot.
|
|
835
|
+
}
|
|
836
|
+
const latestQuestion =
|
|
837
|
+
allQ.find((candidate) => candidate.id === q.id) ?? q;
|
|
510
838
|
downloadJson(
|
|
511
|
-
buildQuestionExport(
|
|
839
|
+
await buildQuestionExport(latestQuestion, allQ),
|
|
512
840
|
`${q.title.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
513
841
|
);
|
|
514
842
|
}}
|
|
@@ -618,6 +946,8 @@ export default function Sidebar() {
|
|
|
618
946
|
</div>
|
|
619
947
|
)}
|
|
620
948
|
{renderMoveQuestionPicker(questions, q, topicId, depth)}
|
|
949
|
+
{renderCopyQuestionPicker(questions, q, topicId, depth)}
|
|
950
|
+
{renderCopyToTopicPicker(q, topicId, depth)}
|
|
621
951
|
{/* Recurse into children — hidden when collapsed */}
|
|
622
952
|
{!isCollapsed &&
|
|
623
953
|
renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
|
|
@@ -661,6 +991,54 @@ export default function Sidebar() {
|
|
|
661
991
|
downloadBase="/api/workspace/context-files"
|
|
662
992
|
label="workspace"
|
|
663
993
|
/>
|
|
994
|
+
{activeWs?.driveConfig?.folderId && isAtDriveFolderRoot && (
|
|
995
|
+
<p className="mt-2 rounded-md border border-slate-800 bg-slate-900/70 px-2 py-1.5 text-[10px] leading-relaxed text-slate-500">
|
|
996
|
+
Select a Drive folder first. Its workspace-files and questions
|
|
997
|
+
sync from inside that selected folder.
|
|
998
|
+
</p>
|
|
999
|
+
)}
|
|
1000
|
+
{canSyncDriveFolder && (
|
|
1001
|
+
<div className="mt-2 space-y-1.5">
|
|
1002
|
+
<p className="text-[10px] text-slate-600">
|
|
1003
|
+
Sync selected folder: {driveSyncTargetName}
|
|
1004
|
+
</p>
|
|
1005
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
1006
|
+
<button
|
|
1007
|
+
type="button"
|
|
1008
|
+
onClick={handlePushToDrive}
|
|
1009
|
+
disabled={pushing || syncing}
|
|
1010
|
+
className="flex items-center justify-center gap-1 rounded-md border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] font-medium text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:hover:bg-cyan-500/10 transition-colors"
|
|
1011
|
+
title="Push topics, questions, and workspace files into the selected Drive folder"
|
|
1012
|
+
>
|
|
1013
|
+
{pushing ? (
|
|
1014
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1015
|
+
) : (
|
|
1016
|
+
<Upload className="w-3 h-3" />
|
|
1017
|
+
)}
|
|
1018
|
+
{pushing ? "Pushing…" : "Push folder"}
|
|
1019
|
+
</button>
|
|
1020
|
+
<button
|
|
1021
|
+
type="button"
|
|
1022
|
+
onClick={handleResync}
|
|
1023
|
+
disabled={syncing || pushing}
|
|
1024
|
+
className="flex items-center justify-center gap-1 rounded-md border border-slate-700 bg-slate-800/60 px-2 py-1 text-[11px] font-medium text-slate-300 hover:border-cyan-500/30 hover:text-cyan-300 disabled:opacity-40 disabled:hover:text-slate-300 transition-colors"
|
|
1025
|
+
title="Pull topics, questions, and workspace files from the selected Drive folder"
|
|
1026
|
+
>
|
|
1027
|
+
{syncing ? (
|
|
1028
|
+
<Loader2 className="w-3 h-3 animate-spin" />
|
|
1029
|
+
) : (
|
|
1030
|
+
<RefreshCw className="w-3 h-3" />
|
|
1031
|
+
)}
|
|
1032
|
+
{syncing ? "Pulling…" : "Pull folder"}
|
|
1033
|
+
</button>
|
|
1034
|
+
</div>
|
|
1035
|
+
{driveFileSyncStatus && (
|
|
1036
|
+
<p className="text-[10px] leading-relaxed text-slate-500">
|
|
1037
|
+
{driveFileSyncStatus}
|
|
1038
|
+
</p>
|
|
1039
|
+
)}
|
|
1040
|
+
</div>
|
|
1041
|
+
)}
|
|
664
1042
|
</div>
|
|
665
1043
|
)}
|
|
666
1044
|
</div>
|
|
@@ -888,12 +1266,22 @@ export default function Sidebar() {
|
|
|
888
1266
|
<Trash2 className="w-3 h-3" />
|
|
889
1267
|
</button>
|
|
890
1268
|
<button
|
|
891
|
-
onClick={(e) => {
|
|
1269
|
+
onClick={async (e) => {
|
|
892
1270
|
e.stopPropagation();
|
|
893
|
-
|
|
1271
|
+
let topicQuestions = questionsByTopic[topic.id] ?? [];
|
|
1272
|
+
try {
|
|
1273
|
+
topicQuestions = await api.fetchQuestions(topic.id);
|
|
1274
|
+
} catch {
|
|
1275
|
+
// Fall back to the already-loaded sidebar snapshot.
|
|
1276
|
+
}
|
|
894
1277
|
const rootQuestions = topicQuestions.filter(
|
|
895
1278
|
(q) => !q.parentQuestionId,
|
|
896
1279
|
);
|
|
1280
|
+
const exportedQuestions = await Promise.all(
|
|
1281
|
+
rootQuestions.map((q) =>
|
|
1282
|
+
buildQuestionExport(q, topicQuestions),
|
|
1283
|
+
),
|
|
1284
|
+
);
|
|
897
1285
|
downloadJson(
|
|
898
1286
|
{
|
|
899
1287
|
id: topic.id,
|
|
@@ -901,9 +1289,7 @@ export default function Sidebar() {
|
|
|
901
1289
|
systemContext: topic.systemContext ?? "",
|
|
902
1290
|
contextFiles: topic.contextFiles,
|
|
903
1291
|
createdAt: topic.createdAt,
|
|
904
|
-
questions:
|
|
905
|
-
buildQuestionExport(q, topicQuestions),
|
|
906
|
-
),
|
|
1292
|
+
questions: exportedQuestions,
|
|
907
1293
|
},
|
|
908
1294
|
`${topic.name.replace(/[^a-z0-9]+/gi, "-")}.json`,
|
|
909
1295
|
);
|
|
@@ -152,6 +152,10 @@ export default function WorkspaceSwitcher() {
|
|
|
152
152
|
setSyncResult(null);
|
|
153
153
|
try {
|
|
154
154
|
const result = await syncWorkspace(ws.id);
|
|
155
|
+
if ("needsAuth" in result && result.needsAuth) {
|
|
156
|
+
window.location.href = result.authUrl;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
155
159
|
setSyncResult(result);
|
|
156
160
|
} catch (err: any) {
|
|
157
161
|
setSyncResult({
|