kcg-code 0.0.0-stage → 0.1.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/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings -> Workspace: folder tempat semua Project berada (Sandbox_Root).
|
|
3
|
+
*
|
|
4
|
+
* - Menampilkan folder aktif + jumlah Project, dan lokasi file config.
|
|
5
|
+
* - "Change folder" membuka dialog: path absolut (`~` didukung), opsi
|
|
6
|
+
* "buat folder bila belum ada", dan konfirmasi PIN/password bila app
|
|
7
|
+
* dikunci. Server menolak folder yang terlalu luas / tumpang tindih data
|
|
8
|
+
* KCG Code, dan menolak bila masih ada Project di luar folder baru —
|
|
9
|
+
* daftar Project itu ditampilkan di dialog.
|
|
10
|
+
* - Perubahan berlaku seketika (tanpa restart) dan disimpan ke config.json.
|
|
11
|
+
*/
|
|
12
|
+
import { CheckIcon, CopyIcon, FolderIcon, FolderOpenIcon, TriangleAlertIcon } from "lucide-react";
|
|
13
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
import { ActionRow, PrefsGroup } from "@/components/settings/prefs";
|
|
16
|
+
import { Button } from "@/components/ui/button";
|
|
17
|
+
import {
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
} from "@/components/ui/dialog";
|
|
25
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
26
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
27
|
+
import { lockLabel } from "@/lib/auth";
|
|
28
|
+
import { cn } from "@/lib/utils";
|
|
29
|
+
import type { AuthStatus } from "@/server/services/auth";
|
|
30
|
+
import type { WorkspaceInfo } from "@/server/services/workspace";
|
|
31
|
+
|
|
32
|
+
type LoadState =
|
|
33
|
+
| { phase: "loading" }
|
|
34
|
+
| { phase: "error"; message: string }
|
|
35
|
+
| { phase: "ready"; workspace: WorkspaceInfo };
|
|
36
|
+
|
|
37
|
+
export function WorkspaceGroup({ status }: { status: AuthStatus }) {
|
|
38
|
+
const [state, setState] = useState<LoadState>({ phase: "loading" });
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
|
|
41
|
+
const load = useCallback(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const res = await apiFetch("/api/workspace");
|
|
44
|
+
setState({
|
|
45
|
+
phase: "ready",
|
|
46
|
+
workspace: ((await res.json()) as { workspace: WorkspaceInfo }).workspace,
|
|
47
|
+
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
setState({
|
|
50
|
+
phase: "error",
|
|
51
|
+
message: e instanceof ApiError ? e.message : "Couldn't load the workspace.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
void load();
|
|
58
|
+
}, [load]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<WorkspaceView state={state} onChange={() => setOpen(true)} />
|
|
63
|
+
{state.phase === "ready" && (
|
|
64
|
+
<ChangeWorkspaceDialog
|
|
65
|
+
open={open}
|
|
66
|
+
status={status}
|
|
67
|
+
current={state.workspace}
|
|
68
|
+
onOpenChange={setOpen}
|
|
69
|
+
onChanged={(w) => {
|
|
70
|
+
setOpen(false);
|
|
71
|
+
setState({ phase: "ready", workspace: w });
|
|
72
|
+
toast.success(`Workspace changed to ${w.display}.`);
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tampilan murni (diuji SSR). */
|
|
81
|
+
export function WorkspaceView({ state, onChange }: { state: LoadState; onChange: () => void }) {
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<PrefsGroup
|
|
85
|
+
title="Workspace folder"
|
|
86
|
+
description="Every project lives inside this folder. KCG Code and your agents can't open anything outside it."
|
|
87
|
+
>
|
|
88
|
+
{state.phase === "loading" ? (
|
|
89
|
+
<li className="flex min-h-16 items-center justify-center px-4" role="status">
|
|
90
|
+
<Spinner className="size-4 text-muted-foreground" />
|
|
91
|
+
<span className="sr-only">Loading workspace…</span>
|
|
92
|
+
</li>
|
|
93
|
+
) : state.phase === "error" ? (
|
|
94
|
+
<li role="alert" className="px-4 py-4 text-[13px] text-destructive">
|
|
95
|
+
{state.message}
|
|
96
|
+
</li>
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
<li className="flex min-h-16 items-center gap-3 px-4 py-3">
|
|
100
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
101
|
+
<FolderOpenIcon className="size-[18px]" aria-hidden />
|
|
102
|
+
</span>
|
|
103
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
104
|
+
<span
|
|
105
|
+
className="truncate font-mono text-[14px] leading-5"
|
|
106
|
+
title={state.workspace.root}
|
|
107
|
+
>
|
|
108
|
+
{state.workspace.display}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="text-[13px] leading-snug text-muted-foreground">
|
|
111
|
+
{state.workspace.projectCount === 1
|
|
112
|
+
? "1 project"
|
|
113
|
+
: `${state.workspace.projectCount} projects`}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
<CopyPathButton value={state.workspace.root} />
|
|
117
|
+
</li>
|
|
118
|
+
<ActionRow
|
|
119
|
+
prefix={
|
|
120
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
121
|
+
<FolderIcon className="size-[18px]" aria-hidden />
|
|
122
|
+
</span>
|
|
123
|
+
}
|
|
124
|
+
title="Change folder"
|
|
125
|
+
subtitle="Pick another folder for new and existing projects"
|
|
126
|
+
onActivate={onChange}
|
|
127
|
+
/>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
</PrefsGroup>
|
|
131
|
+
{state.phase === "ready" && (
|
|
132
|
+
<p className="-mt-4 px-1 text-[12px] text-muted-foreground">
|
|
133
|
+
Saved in <code className="font-mono">{state.workspace.configPath}</code>
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function CopyPathButton({ value }: { value: string }) {
|
|
141
|
+
const [copied, setCopied] = useState(false);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!copied) return;
|
|
144
|
+
const t = setTimeout(() => setCopied(false), 1500);
|
|
145
|
+
return () => clearTimeout(t);
|
|
146
|
+
}, [copied]);
|
|
147
|
+
return (
|
|
148
|
+
<Button
|
|
149
|
+
type="button"
|
|
150
|
+
size="icon"
|
|
151
|
+
variant="ghost"
|
|
152
|
+
className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
|
|
153
|
+
aria-label={copied ? "Copied" : "Copy full path"}
|
|
154
|
+
onClick={() => {
|
|
155
|
+
void navigator.clipboard?.writeText(value).then(() => setCopied(true));
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
159
|
+
</Button>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------- dialog ----
|
|
164
|
+
|
|
165
|
+
interface BlockingProject {
|
|
166
|
+
id: string;
|
|
167
|
+
name: string;
|
|
168
|
+
path: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ChangeWorkspaceDialog({
|
|
172
|
+
open,
|
|
173
|
+
status,
|
|
174
|
+
current,
|
|
175
|
+
onOpenChange,
|
|
176
|
+
onChanged,
|
|
177
|
+
}: {
|
|
178
|
+
open: boolean;
|
|
179
|
+
status: AuthStatus;
|
|
180
|
+
current: WorkspaceInfo;
|
|
181
|
+
onOpenChange: (open: boolean) => void;
|
|
182
|
+
onChanged: (w: WorkspaceInfo) => void;
|
|
183
|
+
}) {
|
|
184
|
+
const pathId = useId();
|
|
185
|
+
const secretId = useId();
|
|
186
|
+
const createId = useId();
|
|
187
|
+
const pathRef = useRef<HTMLInputElement>(null);
|
|
188
|
+
const [value, setValue] = useState(current.display);
|
|
189
|
+
const [create, setCreate] = useState(false);
|
|
190
|
+
const [secret, setSecret] = useState("");
|
|
191
|
+
const [busy, setBusy] = useState(false);
|
|
192
|
+
const [error, setError] = useState<string | null>(null);
|
|
193
|
+
const [blocking, setBlocking] = useState<BlockingProject[]>([]);
|
|
194
|
+
const kind = status.lockKind ?? "password";
|
|
195
|
+
const needsSecret = status.protected;
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!open) return;
|
|
199
|
+
setValue(current.display);
|
|
200
|
+
setCreate(false);
|
|
201
|
+
setSecret("");
|
|
202
|
+
setError(null);
|
|
203
|
+
setBlocking([]);
|
|
204
|
+
const t = setTimeout(() => pathRef.current?.select(), 50);
|
|
205
|
+
return () => clearTimeout(t);
|
|
206
|
+
}, [open, current.display]);
|
|
207
|
+
|
|
208
|
+
const unchanged = value.trim() === current.display || value.trim() === current.root;
|
|
209
|
+
|
|
210
|
+
const submit = async (e: React.FormEvent) => {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
if (unchanged) return;
|
|
213
|
+
setBusy(true);
|
|
214
|
+
setError(null);
|
|
215
|
+
setBlocking([]);
|
|
216
|
+
try {
|
|
217
|
+
const res = await apiFetch("/api/workspace", {
|
|
218
|
+
method: "PUT",
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
path: value.trim(),
|
|
221
|
+
create,
|
|
222
|
+
current: needsSecret ? secret : undefined,
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
onChanged(((await res.json()) as { workspace: WorkspaceInfo }).workspace);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// 409 membawa daftar Project yang menghalangi di body respons.
|
|
228
|
+
if (err instanceof ApiError && err.code === "WORKSPACE_PROJECTS_OUTSIDE") {
|
|
229
|
+
const list = err.body?.projects;
|
|
230
|
+
setBlocking(Array.isArray(list) ? (list as BlockingProject[]) : []);
|
|
231
|
+
}
|
|
232
|
+
if (err instanceof ApiError && err.code === "CURRENT_SECRET_INVALID") setSecret("");
|
|
233
|
+
setError(err instanceof ApiError ? err.message : "Couldn't change the workspace.");
|
|
234
|
+
} finally {
|
|
235
|
+
setBusy(false);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
|
241
|
+
<DialogContent className="gap-5 sm:max-w-lg">
|
|
242
|
+
<DialogHeader>
|
|
243
|
+
<DialogTitle>Change workspace folder</DialogTitle>
|
|
244
|
+
<DialogDescription>
|
|
245
|
+
Projects and agents will only be able to use files inside the new folder. Nothing is
|
|
246
|
+
moved or deleted.
|
|
247
|
+
</DialogDescription>
|
|
248
|
+
</DialogHeader>
|
|
249
|
+
|
|
250
|
+
<form id="workspace-form" onSubmit={submit} className="flex flex-col gap-4">
|
|
251
|
+
<div className="flex flex-col gap-2">
|
|
252
|
+
<div className="overflow-hidden rounded-xl border bg-card">
|
|
253
|
+
<div className="flex flex-col divide-y">
|
|
254
|
+
<div className="flex flex-col px-4 py-2 focus-within:bg-muted/30">
|
|
255
|
+
<label htmlFor={pathId} className="text-[12px] leading-4 text-muted-foreground">
|
|
256
|
+
Folder path
|
|
257
|
+
</label>
|
|
258
|
+
<input
|
|
259
|
+
ref={pathRef}
|
|
260
|
+
id={pathId}
|
|
261
|
+
value={value}
|
|
262
|
+
maxLength={1024}
|
|
263
|
+
autoComplete="off"
|
|
264
|
+
autoCapitalize="off"
|
|
265
|
+
autoCorrect="off"
|
|
266
|
+
spellCheck={false}
|
|
267
|
+
placeholder="~/code"
|
|
268
|
+
disabled={busy}
|
|
269
|
+
onChange={(e) => {
|
|
270
|
+
setValue(e.target.value);
|
|
271
|
+
setError(null);
|
|
272
|
+
setBlocking([]);
|
|
273
|
+
}}
|
|
274
|
+
// 16px di HP: iOS tidak men-zoom saat fokus.
|
|
275
|
+
className="h-8 w-full bg-transparent font-mono text-base outline-none placeholder:text-muted-foreground/60 sm:text-[14px]"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
{needsSecret && (
|
|
279
|
+
<div className="flex flex-col px-4 py-2 focus-within:bg-muted/30">
|
|
280
|
+
<label
|
|
281
|
+
htmlFor={secretId}
|
|
282
|
+
className="text-[12px] leading-4 text-muted-foreground"
|
|
283
|
+
>
|
|
284
|
+
Current {lockLabel(kind)}
|
|
285
|
+
</label>
|
|
286
|
+
<input
|
|
287
|
+
id={secretId}
|
|
288
|
+
type="password"
|
|
289
|
+
inputMode={kind === "pin" ? "numeric" : undefined}
|
|
290
|
+
autoComplete="current-password"
|
|
291
|
+
maxLength={kind === "pin" ? 12 : 128}
|
|
292
|
+
value={secret}
|
|
293
|
+
disabled={busy}
|
|
294
|
+
onChange={(e) => {
|
|
295
|
+
setSecret(
|
|
296
|
+
kind === "pin" ? e.target.value.replace(/\D/g, "") : e.target.value,
|
|
297
|
+
);
|
|
298
|
+
setError(null);
|
|
299
|
+
}}
|
|
300
|
+
className={cn(
|
|
301
|
+
"h-8 w-full bg-transparent text-base outline-none sm:text-[15px]",
|
|
302
|
+
kind === "pin" && "tracking-[0.3em]",
|
|
303
|
+
)}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<p className="px-1 text-[12px] leading-snug text-muted-foreground">
|
|
310
|
+
Use a full path like <code className="font-mono">~/code</code> or{" "}
|
|
311
|
+
<code className="font-mono">/srv/projects</code>. Your home folder, system folders,
|
|
312
|
+
and the KCG Code data folder can't be used.
|
|
313
|
+
</p>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<label
|
|
317
|
+
htmlFor={createId}
|
|
318
|
+
className="flex cursor-pointer items-center gap-3 rounded-xl px-1 text-[14px]"
|
|
319
|
+
>
|
|
320
|
+
<input
|
|
321
|
+
id={createId}
|
|
322
|
+
type="checkbox"
|
|
323
|
+
checked={create}
|
|
324
|
+
disabled={busy}
|
|
325
|
+
onChange={(e) => setCreate(e.target.checked)}
|
|
326
|
+
className="size-4 accent-primary"
|
|
327
|
+
/>
|
|
328
|
+
Create the folder if it doesn't exist
|
|
329
|
+
</label>
|
|
330
|
+
|
|
331
|
+
{error && (
|
|
332
|
+
<div
|
|
333
|
+
role="alert"
|
|
334
|
+
className="flex flex-col gap-2 rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm"
|
|
335
|
+
>
|
|
336
|
+
<p className="flex items-start gap-2 text-destructive">
|
|
337
|
+
<TriangleAlertIcon className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
338
|
+
{error}
|
|
339
|
+
</p>
|
|
340
|
+
{blocking.length > 0 && (
|
|
341
|
+
<ul className="flex flex-col gap-1 pl-6 text-[13px] text-foreground/80">
|
|
342
|
+
{blocking.map((p) => (
|
|
343
|
+
<li key={p.id} className="truncate">
|
|
344
|
+
<span className="font-medium">{p.name}</span>{" "}
|
|
345
|
+
<span className="font-mono text-muted-foreground">{p.path}</span>
|
|
346
|
+
</li>
|
|
347
|
+
))}
|
|
348
|
+
</ul>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</form>
|
|
353
|
+
|
|
354
|
+
<DialogFooter>
|
|
355
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
|
|
356
|
+
Cancel
|
|
357
|
+
</Button>
|
|
358
|
+
<Button
|
|
359
|
+
type="submit"
|
|
360
|
+
form="workspace-form"
|
|
361
|
+
disabled={busy || unchanged || value.trim() === "" || (needsSecret && secret === "")}
|
|
362
|
+
>
|
|
363
|
+
{busy && <Spinner data-icon="inline-start" />}
|
|
364
|
+
Change folder
|
|
365
|
+
</Button>
|
|
366
|
+
</DialogFooter>
|
|
367
|
+
</DialogContent>
|
|
368
|
+
</Dialog>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { AboutView, formatVersion, githubUrl, TEAM } from "../AboutGroup";
|
|
4
|
+
|
|
5
|
+
describe("About", () => {
|
|
6
|
+
test("formatVersion: 'dev' apa adanya, semver diberi prefix v", () => {
|
|
7
|
+
expect(formatVersion("dev")).toBe("dev");
|
|
8
|
+
expect(formatVersion("0.1.0")).toBe("v0.1.0");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("logo + nama + versi", () => {
|
|
12
|
+
const out = renderToStaticMarkup(<AboutView version="0.1.0" failed={false} />);
|
|
13
|
+
expect(out).toContain('alt="KCG Code logo"');
|
|
14
|
+
expect(out).toContain("KCG Code");
|
|
15
|
+
expect(out).toContain(">v0.1.0<");
|
|
16
|
+
// Logo tampil SEBELUM versi.
|
|
17
|
+
expect(out.indexOf("KCG Code logo")).toBeLessThan(out.indexOf("v0.1.0"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("mode development menampilkan 'dev'", () => {
|
|
21
|
+
const out = renderToStaticMarkup(<AboutView version="dev" failed={false} />);
|
|
22
|
+
expect(out).toContain(">dev<");
|
|
23
|
+
expect(out).not.toContain("vdev");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("versi gagal dimuat -> pesan, bukan skeleton", () => {
|
|
27
|
+
const out = renderToStaticMarkup(<AboutView version={null} failed={true} />);
|
|
28
|
+
expect(out).toContain("Version unavailable");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("credits: username, peran, tautan GitHub di tab baru", () => {
|
|
32
|
+
const out = renderToStaticMarkup(<AboutView version="dev" failed={false} />);
|
|
33
|
+
expect(TEAM).toEqual([
|
|
34
|
+
{ username: "irsyadulibad", role: "Lead Developer" },
|
|
35
|
+
{ username: "sahrullahh", role: "Product Advisor" },
|
|
36
|
+
]);
|
|
37
|
+
expect(out).toContain('href="https://github.com/irsyadulibad"');
|
|
38
|
+
expect(out).toContain('href="https://github.com/sahrullahh"');
|
|
39
|
+
expect(out).toContain("@irsyadulibad");
|
|
40
|
+
expect(out).toContain("Lead Developer");
|
|
41
|
+
expect(out).toContain("@sahrullahh");
|
|
42
|
+
expect(out).toContain("Product Advisor");
|
|
43
|
+
expect(out.match(/target="_blank"/g)).toHaveLength(2);
|
|
44
|
+
expect(out.match(/rel="noopener noreferrer"/g)).toHaveLength(2);
|
|
45
|
+
expect(githubUrl("irsyadulibad")).toBe("https://github.com/irsyadulibad");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey UI — render statis: grup Settings (kosong / daftar / tidak
|
|
3
|
+
* didukung) dan tampilan passkey di lock screen.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
7
|
+
import type { PasskeyInfo } from "@/lib/passkeys";
|
|
8
|
+
import { PasskeyEntry } from "../../auth/LockScreen";
|
|
9
|
+
import { PasskeysView } from "../PasskeysGroup";
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
const noopAsync = async () => {};
|
|
13
|
+
|
|
14
|
+
const pk = (over: Partial<PasskeyInfo> = {}): PasskeyInfo => ({
|
|
15
|
+
id: "p1",
|
|
16
|
+
name: "Passkey on iPhone",
|
|
17
|
+
rpId: "me.kcgcode.dev",
|
|
18
|
+
synced: true,
|
|
19
|
+
createdAt: Date.now() - 2 * 86_400_000,
|
|
20
|
+
lastUsedAt: null,
|
|
21
|
+
usableHere: true,
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function view(state: Parameters<typeof PasskeysView>[0]["state"], supported: boolean | null) {
|
|
26
|
+
return renderToStaticMarkup(
|
|
27
|
+
<PasskeysView
|
|
28
|
+
state={state}
|
|
29
|
+
supported={supported}
|
|
30
|
+
onAdd={noop}
|
|
31
|
+
onRename={noopAsync}
|
|
32
|
+
onRemove={noop}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("Settings -> Passkeys", () => {
|
|
38
|
+
test("kosong: ajakan menambah + tombol Add", () => {
|
|
39
|
+
const out = view({ phase: "ready", passkeys: [] }, true);
|
|
40
|
+
expect(out).toContain("Passkeys");
|
|
41
|
+
expect(out).toContain("No passkeys yet");
|
|
42
|
+
expect(out).toContain("Add a passkey");
|
|
43
|
+
expect(out).toContain("still works as a backup");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("alamat tidak didukung: tombol Add nonaktif + penjelasan", () => {
|
|
47
|
+
const out = view({ phase: "ready", passkeys: [] }, false);
|
|
48
|
+
expect(out).toContain("Passkeys need a secure address");
|
|
49
|
+
expect(out).not.toContain("Add a passkey");
|
|
50
|
+
expect(out).toMatch(/disabled=""[^>]*>.*Add<\/button>/s);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("daftar: nama, badge Synced, belum pernah dipakai, aksi rename/hapus berlabel", () => {
|
|
54
|
+
const out = view({ phase: "ready", passkeys: [pk()] }, true);
|
|
55
|
+
expect(out).toContain("Passkey on iPhone");
|
|
56
|
+
expect(out).toContain("Synced");
|
|
57
|
+
expect(out).toContain("Never used");
|
|
58
|
+
expect(out).toContain('aria-label="Rename Passkey on iPhone"');
|
|
59
|
+
expect(out).toContain('aria-label="Remove Passkey on iPhone"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("passkey alamat lain: tampil dengan label alamatnya", () => {
|
|
63
|
+
const out = view(
|
|
64
|
+
{ phase: "ready", passkeys: [pk({ usableHere: false, rpId: "localhost" })] },
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
expect(out).toContain("For localhost");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("error memuat -> pesan alert", () => {
|
|
71
|
+
const out = view({ phase: "error", message: "Couldn't load passkeys." }, true);
|
|
72
|
+
expect(out).toContain('role="alert"');
|
|
73
|
+
expect(out).toContain("Couldn't load passkeys.");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("Lock screen -> passkey", () => {
|
|
78
|
+
const entry = (over: Partial<Parameters<typeof PasskeyEntry>[0]> = {}) =>
|
|
79
|
+
renderToStaticMarkup(
|
|
80
|
+
<PasskeyEntry
|
|
81
|
+
busy={false}
|
|
82
|
+
disabled={false}
|
|
83
|
+
message={null}
|
|
84
|
+
secretLabel="PIN"
|
|
85
|
+
onUnlock={noop}
|
|
86
|
+
onUseSecret={noop}
|
|
87
|
+
{...over}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
test("tombol utama + jalan pintas ke PIN", () => {
|
|
92
|
+
const out = entry();
|
|
93
|
+
expect(out).toContain("Unlock with passkey");
|
|
94
|
+
expect(out).toContain("Tap to continue");
|
|
95
|
+
expect(out).toContain("Use PIN instead");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("menunggu perangkat: status busy & teks petunjuk", () => {
|
|
99
|
+
const out = entry({ busy: true, disabled: true });
|
|
100
|
+
expect(out).toContain('aria-busy="true"');
|
|
101
|
+
expect(out).toContain("Confirm on your device");
|
|
102
|
+
expect(out).toContain("Waiting for your passkey");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("pesan error tampil di area status (aria-live)", () => {
|
|
106
|
+
const out = entry({ message: "That passkey wasn't accepted." });
|
|
107
|
+
expect(out).toMatch(/aria-live="polite"[^>]*>That passkey wasn't accepted\./);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings -> Workspace — render statis.
|
|
3
|
+
*/
|
|
4
|
+
import { expect, test } from "bun:test";
|
|
5
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
6
|
+
import { WorkspaceView } from "../WorkspaceGroup";
|
|
7
|
+
|
|
8
|
+
const ready = {
|
|
9
|
+
phase: "ready" as const,
|
|
10
|
+
workspace: {
|
|
11
|
+
root: "/home/me/code",
|
|
12
|
+
display: "~/code",
|
|
13
|
+
projectCount: 3,
|
|
14
|
+
configPath: "/home/me/.kcgcode/config.json",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
test("menampilkan folder aktif (dengan ~), jumlah project, dan lokasi config", () => {
|
|
19
|
+
const out = renderToStaticMarkup(<WorkspaceView state={ready} onChange={() => {}} />);
|
|
20
|
+
expect(out).toContain("Workspace folder");
|
|
21
|
+
expect(out).toContain("~/code");
|
|
22
|
+
expect(out).toContain('title="/home/me/code"');
|
|
23
|
+
expect(out).toContain("3 projects");
|
|
24
|
+
expect(out).toContain("Change folder");
|
|
25
|
+
expect(out).toContain("/home/me/.kcgcode/config.json");
|
|
26
|
+
expect(out).toContain("can't open anything outside it");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("satu project -> bentuk tunggal; tombol salin berlabel", () => {
|
|
30
|
+
const out = renderToStaticMarkup(
|
|
31
|
+
<WorkspaceView
|
|
32
|
+
state={{ ...ready, workspace: { ...ready.workspace, projectCount: 1 } }}
|
|
33
|
+
onChange={() => {}}
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
expect(out).toContain(">1 project<");
|
|
37
|
+
expect(out).toContain('aria-label="Copy full path"');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("loading & error", () => {
|
|
41
|
+
expect(
|
|
42
|
+
renderToStaticMarkup(<WorkspaceView state={{ phase: "loading" }} onChange={() => {}} />),
|
|
43
|
+
).toContain('role="status"');
|
|
44
|
+
expect(
|
|
45
|
+
renderToStaticMarkup(
|
|
46
|
+
<WorkspaceView state={{ phase: "error", message: "Nope" }} onChange={() => {}} />,
|
|
47
|
+
),
|
|
48
|
+
).toContain('role="alert"');
|
|
49
|
+
});
|