kcg-code 0.0.0-stage → 0.2.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 +230 -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 +84 -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__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -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 +147 -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 +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -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 +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -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 +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -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 +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -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 +256 -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 +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -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 +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -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/search-entry.tsx +70 -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 +114 -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 +101 -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 +327 -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 +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -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 +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -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 +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -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 +362 -0
- package/src/lib/skills-catalog.ts +138 -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 +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -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 +427 -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 +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -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__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -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 +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -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/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -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 +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toaster shadcn/ui (Sonner). Disesuaikan dari `shadcn add sonner`: tema
|
|
3
|
+
* mengikuti `useTheme` milik proyek (bukan `next-themes`).
|
|
4
|
+
*
|
|
5
|
+
* Pakai: `import { toast } from "sonner"` lalu `toast.success(...)` /
|
|
6
|
+
* `toast.error(...)`. Mount `<Toaster />` sekali di akar aplikasi.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
CircleCheckIcon,
|
|
10
|
+
InfoIcon,
|
|
11
|
+
Loader2Icon,
|
|
12
|
+
OctagonXIcon,
|
|
13
|
+
TriangleAlertIcon,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
|
16
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
17
|
+
|
|
18
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
19
|
+
const { theme } = useTheme();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Sonner
|
|
23
|
+
theme={theme}
|
|
24
|
+
className="toaster group"
|
|
25
|
+
icons={{
|
|
26
|
+
success: <CircleCheckIcon className="size-4" />,
|
|
27
|
+
info: <InfoIcon className="size-4" />,
|
|
28
|
+
warning: <TriangleAlertIcon className="size-4" />,
|
|
29
|
+
error: <OctagonXIcon className="size-4" />,
|
|
30
|
+
loading: <Loader2Icon className="size-4 animate-spin" />,
|
|
31
|
+
}}
|
|
32
|
+
style={
|
|
33
|
+
{
|
|
34
|
+
"--normal-bg": "var(--popover)",
|
|
35
|
+
"--normal-text": "var(--popover-foreground)",
|
|
36
|
+
"--normal-border": "var(--border)",
|
|
37
|
+
"--border-radius": "var(--radius)",
|
|
38
|
+
} as React.CSSProperties
|
|
39
|
+
}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { Toaster };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Loader2Icon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
6
|
+
return (
|
|
7
|
+
<Loader2Icon
|
|
8
|
+
role="status"
|
|
9
|
+
aria-label="Loading"
|
|
10
|
+
className={cn("size-4 animate-spin", className)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { Spinner };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Switch as SwitchPrimitive } from "radix-ui";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Switch({
|
|
6
|
+
className,
|
|
7
|
+
size = "default",
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
|
10
|
+
size?: "sm" | "default";
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<SwitchPrimitive.Root
|
|
14
|
+
data-slot="switch"
|
|
15
|
+
data-size={size}
|
|
16
|
+
className={cn(
|
|
17
|
+
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<SwitchPrimitive.Thumb
|
|
23
|
+
data-slot="switch-thumb"
|
|
24
|
+
className={cn(
|
|
25
|
+
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
|
|
26
|
+
)}
|
|
27
|
+
/>
|
|
28
|
+
</SwitchPrimitive.Root>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { Switch };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
6
|
+
return (
|
|
7
|
+
<textarea
|
|
8
|
+
data-slot="textarea"
|
|
9
|
+
className={cn(
|
|
10
|
+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Textarea };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
function TooltipProvider({
|
|
7
|
+
delayDuration = 0,
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
10
|
+
return (
|
|
11
|
+
<TooltipPrimitive.Provider
|
|
12
|
+
data-slot="tooltip-provider"
|
|
13
|
+
delayDuration={delayDuration}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
20
|
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
24
|
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function TooltipContent({
|
|
28
|
+
className,
|
|
29
|
+
sideOffset = 0,
|
|
30
|
+
children,
|
|
31
|
+
...props
|
|
32
|
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
33
|
+
return (
|
|
34
|
+
<TooltipPrimitive.Portal>
|
|
35
|
+
<TooltipPrimitive.Content
|
|
36
|
+
data-slot="tooltip-content"
|
|
37
|
+
sideOffset={sideOffset}
|
|
38
|
+
className={cn(
|
|
39
|
+
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
|
46
|
+
</TooltipPrimitive.Content>
|
|
47
|
+
</TooltipPrimitive.Portal>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config_File & validasi Sandbox_Root saat startup.
|
|
3
|
+
* Sesuai `design.md` — `config.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Skema minimal: `{ "sandboxRoot": "/abs/path" }`, opsional `"port": 3000`.
|
|
6
|
+
* Path default mengikuti mode runtime (`src/runtime.ts`):
|
|
7
|
+
* - **cli** (`kcgcode`): `~/.kcgcode/config.json`
|
|
8
|
+
* - **dev** (`bun dev`): `./kcg-code.config.json` bila ada, else user config
|
|
9
|
+
* `loadConfig()` keluar dengan kode error bila `sandboxRoot` tidak diatur
|
|
10
|
+
* atau direktori yang ditunjuk tidak ditemukan (Requirement 10.1).
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { DEFAULT_CONFIG_PATH, resolveEffectiveConfigPath } from "./paths";
|
|
14
|
+
import type { Result } from "./server/result";
|
|
15
|
+
|
|
16
|
+
export { DEFAULT_CONFIG_PATH };
|
|
17
|
+
|
|
18
|
+
export interface AppConfig {
|
|
19
|
+
/** Path absolut hasil `realpath` dari `sandboxRoot`. */
|
|
20
|
+
sandboxRoot: string;
|
|
21
|
+
/** Path berkas konfigurasi yang dibaca. */
|
|
22
|
+
configPath: string;
|
|
23
|
+
/** Port HTTP pilihan user (diatur lewat menu CLI); kosong = default. */
|
|
24
|
+
port?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isValidPort(n: unknown): n is number {
|
|
28
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 65535;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Membaca dan memvalidasi Config_File tanpa menghentikan proses.
|
|
33
|
+
* Dipisah dari `loadConfig()` agar dapat diuji secara unit (task 2.2).
|
|
34
|
+
*/
|
|
35
|
+
export function resolveConfig(configPath?: string): Result<AppConfig> {
|
|
36
|
+
const file = resolveEffectiveConfigPath(configPath);
|
|
37
|
+
|
|
38
|
+
let raw: string;
|
|
39
|
+
try {
|
|
40
|
+
raw = readFileSync(file, "utf8");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `[config] cannot read config file "${file}": ${(err as Error).message}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let parsed: unknown;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: `[config] invalid JSON in "${file}": ${(err as Error).message}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const obj = parsed as Record<string, unknown>;
|
|
59
|
+
const sandboxRoot = obj.sandboxRoot;
|
|
60
|
+
if (typeof sandboxRoot !== "string" || sandboxRoot.trim() === "") {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `[config] "sandboxRoot" is not set in "${file}"`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let resolved: string;
|
|
68
|
+
try {
|
|
69
|
+
resolved = realpathSync(sandboxRoot);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `[config] sandboxRoot directory not found: ${sandboxRoot} (${(err as Error).message})`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const port = obj.port;
|
|
78
|
+
if (port !== undefined && !isValidPort(port)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `[config] "port" must be an integer 1-65535 in "${file}" (got ${JSON.stringify(port)})`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
data: { sandboxRoot: resolved, configPath: file, ...(port !== undefined && { port }) },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Simpan `port` ke Config_File; key lain dipertahankan apa adanya. */
|
|
92
|
+
export function saveConfigPort(configPath: string, port: number): Result<void> {
|
|
93
|
+
try {
|
|
94
|
+
const obj = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
95
|
+
obj.port = port;
|
|
96
|
+
writeFileSync(configPath, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
|
|
97
|
+
return { ok: true, data: undefined };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ok: false, error: `cannot save port to "${configPath}": ${(err as Error).message}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Memuat konfigurasi saat startup. Bila tidak valid, log pesan jelas dan
|
|
105
|
+
* proses keluar dengan kode error (Requirement 10.1).
|
|
106
|
+
*/
|
|
107
|
+
export function loadConfig(): AppConfig {
|
|
108
|
+
const res = resolveConfig();
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
console.error(res.error);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
return res.data;
|
|
114
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Session_Store` — komposisi root persistensi `bun:sqlite` (headless).
|
|
3
|
+
* Sesuai `design.md`: koneksi DB + migrasi + penyatuan repository per-domain.
|
|
4
|
+
*
|
|
5
|
+
* Lapisan repository hidup di `src/server/db/`:
|
|
6
|
+
* - `schema.ts` — skema SQLite & migrasi idempoten
|
|
7
|
+
* - `rows.ts` — pemetaan baris SQLite -> tipe domain
|
|
8
|
+
* - `projects.ts` — repository Projects
|
|
9
|
+
* - `sessions.ts` — repository Sessions + riwayat status (append-only)
|
|
10
|
+
* - `messages.ts` — repository pesan terstruktur (append-only)
|
|
11
|
+
* - `prompts.ts` — repository prompt interaktif
|
|
12
|
+
* - `auth.ts` — kunci aplikasi (lock screen) & sesi login
|
|
13
|
+
* - `tunnel.ts` — akun tunnel publik (Google + kcgcode-rp)
|
|
14
|
+
*
|
|
15
|
+
* Konvensi: `session_status_history` dan `messages` bersifat **append-only**
|
|
16
|
+
* (insert-only) sehingga Requirement 3.1/3.3 terpenuhi secara struktural.
|
|
17
|
+
* Seluruh operasi tulis dibungkus try/catch; kegagalan mengembalikan
|
|
18
|
+
* `{ ok: false, error }` tanpa menghapus data lama (Requirement 3.2).
|
|
19
|
+
*/
|
|
20
|
+
import { Database } from "bun:sqlite";
|
|
21
|
+
import { mkdirSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { DEFAULT_DB_PATH, resolveEffectiveDbPath } from "./paths";
|
|
24
|
+
import { type AuthRepo, createAuthRepo } from "./server/db/auth";
|
|
25
|
+
import { createMessageRepo, type MessageRepo } from "./server/db/messages";
|
|
26
|
+
import { createProjectRepo, type ProjectRepo } from "./server/db/projects";
|
|
27
|
+
import { createPromptRepo, type PromptRepo } from "./server/db/prompts";
|
|
28
|
+
import { runMigrations } from "./server/db/schema";
|
|
29
|
+
import { createSessionRepo, type SessionRepo, type StatusHistoryEntry } from "./server/db/sessions";
|
|
30
|
+
import { createTunnelRepo, type TunnelRepo } from "./server/db/tunnel";
|
|
31
|
+
import type { Result, SimpleResult } from "./server/result";
|
|
32
|
+
import type {
|
|
33
|
+
InteractivePrompt,
|
|
34
|
+
Project,
|
|
35
|
+
PromptStatus,
|
|
36
|
+
Session,
|
|
37
|
+
SessionMessage,
|
|
38
|
+
SessionModel,
|
|
39
|
+
SessionStatus,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
export { DEFAULT_DB_PATH };
|
|
43
|
+
|
|
44
|
+
export interface SessionStore extends AuthRepo, TunnelRepo {
|
|
45
|
+
// ---- Pesan terstruktur (append-only) ----
|
|
46
|
+
insertMessage(message: SessionMessage): Result<SessionMessage>;
|
|
47
|
+
getMessages(sessionId: string): Result<SessionMessage[]>;
|
|
48
|
+
|
|
49
|
+
// ---- Riwayat status (append-only) ----
|
|
50
|
+
insertStatusHistory(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
status: SessionStatus,
|
|
53
|
+
changedAt?: number,
|
|
54
|
+
): Result<StatusHistoryEntry>;
|
|
55
|
+
getStatusHistory(sessionId: string): Result<StatusHistoryEntry[]>;
|
|
56
|
+
|
|
57
|
+
// ---- Sessions (CRUD) ----
|
|
58
|
+
insertSession(session: Session): Result<Session>;
|
|
59
|
+
updateSessionStatus(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
status: SessionStatus,
|
|
62
|
+
changedAt?: number,
|
|
63
|
+
): Result<Session>;
|
|
64
|
+
/** Perbarui ocSessionId tanpa mengubah status (dipakai resumeSession). */
|
|
65
|
+
updateSessionOcId(sessionId: string, ocSessionId: string | null): Result<Session>;
|
|
66
|
+
/** Perbarui model pilihan Session; `null` = kembali ke default opencode. */
|
|
67
|
+
updateSessionModel(sessionId: string, model: SessionModel | null): Result<Session>;
|
|
68
|
+
/** Perbarui agent (mode) pilihan Session; `null` = default opencode. */
|
|
69
|
+
updateSessionAgent(sessionId: string, agent: string | null): Result<Session>;
|
|
70
|
+
/** Simpan judul Session hasil generate opencode (SSE `session.updated`). */
|
|
71
|
+
updateSessionTitle(sessionId: string, title: string): Result<Session>;
|
|
72
|
+
getSession(sessionId: string): Result<Session>;
|
|
73
|
+
getSessionByOcId(ocSessionId: string): Result<Session>;
|
|
74
|
+
listSessions(): Session[];
|
|
75
|
+
/**
|
|
76
|
+
* Hapus Session beserta seluruh baris anaknya (messages, prompts,
|
|
77
|
+
* status history) dalam satu transaksi. `ok: false` bila tidak ada.
|
|
78
|
+
*/
|
|
79
|
+
deleteSession(sessionId: string): SimpleResult;
|
|
80
|
+
|
|
81
|
+
// ---- Projects (CRUD) ----
|
|
82
|
+
insertProject(project: Project): Result<Project>;
|
|
83
|
+
getProjectById(id: string): Result<Project>;
|
|
84
|
+
getProjectByName(name: string): Result<Project>;
|
|
85
|
+
getProjectByPath(filePath: string): Result<Project>;
|
|
86
|
+
listProjects(): Project[];
|
|
87
|
+
/** Simpan custom instruction Project; `null` = hapus instruksi. */
|
|
88
|
+
updateProjectInstructions(projectId: string, instructions: string | null): Result<Project>;
|
|
89
|
+
/** Session milik satu Project (dipakai sebelum menghapus Project). */
|
|
90
|
+
listProjectSessions(projectId: string): Session[];
|
|
91
|
+
/**
|
|
92
|
+
* Hapus baris Project. Menolak (`PROJECT_HAS_SESSIONS`) bila masih ada
|
|
93
|
+
* Session yang menunjuk Project ini — Session harus dihapus lebih dulu
|
|
94
|
+
* lewat `SessionManager.deleteSession` agar sesi remote opencode dan
|
|
95
|
+
* lampirannya ikut dibersihkan, bukan ditinggal yatim.
|
|
96
|
+
*/
|
|
97
|
+
deleteProject(projectId: string): SimpleResult;
|
|
98
|
+
|
|
99
|
+
// ---- Prompts (CRUD) ----
|
|
100
|
+
insertPrompt(prompt: InteractivePrompt): Result<InteractivePrompt>;
|
|
101
|
+
getPrompt(promptId: string): Result<InteractivePrompt>;
|
|
102
|
+
listPendingPrompts(sessionId: string): InteractivePrompt[];
|
|
103
|
+
updatePromptStatus(
|
|
104
|
+
promptId: string,
|
|
105
|
+
status: PromptStatus,
|
|
106
|
+
resolvedAt?: number,
|
|
107
|
+
): Result<InteractivePrompt>;
|
|
108
|
+
|
|
109
|
+
close(): void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Membuka koneksi ke database, menyalakan WAL, menjalankan migrasi
|
|
114
|
+
* idempoten, lalu menyatukan repository per-domain menjadi satu `SessionStore`.
|
|
115
|
+
* Default sesuai mode: dev `./data/kcg-code.sqlite`, cli `~/.kcgcode/data/...`.
|
|
116
|
+
*/
|
|
117
|
+
export function openSessionStore(dbPath?: string): SessionStore {
|
|
118
|
+
const resolvedPath = dbPath ?? resolveEffectiveDbPath();
|
|
119
|
+
if (resolvedPath !== ":memory:") {
|
|
120
|
+
mkdirSync(path.dirname(path.resolve(resolvedPath)), { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
const db = new Database(resolvedPath, { create: true });
|
|
123
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
124
|
+
runMigrations(db);
|
|
125
|
+
|
|
126
|
+
const projects: ProjectRepo = createProjectRepo(db);
|
|
127
|
+
const sessions: SessionRepo = createSessionRepo(db);
|
|
128
|
+
const messages: MessageRepo = createMessageRepo(db);
|
|
129
|
+
const prompts: PromptRepo = createPromptRepo(db);
|
|
130
|
+
const auth: AuthRepo = createAuthRepo(db);
|
|
131
|
+
const tunnel: TunnelRepo = createTunnelRepo(db);
|
|
132
|
+
|
|
133
|
+
const store: SessionStore = {
|
|
134
|
+
...projects,
|
|
135
|
+
...sessions,
|
|
136
|
+
...messages,
|
|
137
|
+
...prompts,
|
|
138
|
+
...auth,
|
|
139
|
+
...tunnel,
|
|
140
|
+
close() {
|
|
141
|
+
db.close();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return store;
|
|
146
|
+
}
|
package/src/frontend.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is the entry point for the React app, it sets up the root
|
|
3
|
+
* element and renders the App component to the DOM.
|
|
4
|
+
*
|
|
5
|
+
* It is included in `src/index.html`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { StrictMode } from "react";
|
|
9
|
+
import { createRoot } from "react-dom/client";
|
|
10
|
+
import { App } from "./App";
|
|
11
|
+
|
|
12
|
+
const elem = document.getElementById("root");
|
|
13
|
+
if (!elem) {
|
|
14
|
+
throw new Error("Root element #root not found in index.html");
|
|
15
|
+
}
|
|
16
|
+
const app = (
|
|
17
|
+
<StrictMode>
|
|
18
|
+
<App />
|
|
19
|
+
</StrictMode>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data
|
|
23
|
+
//
|
|
24
|
+
// API HMR (`import.meta.hot`) hanya ada saat `bun --hot`; pada build produksi
|
|
25
|
+
// Bun mengganti/menghilangkannya, jadi pola di bawah memakai guard literal
|
|
26
|
+
// `if (import.meta.hot)` yang di-tree-shake bundler saat produksi — blok else
|
|
27
|
+
// itulah yang tersisa di bundle produksi (createRoot sekali, tanpa HMR API).
|
|
28
|
+
if (import.meta.hot) {
|
|
29
|
+
if (!import.meta.hot.data.root) {
|
|
30
|
+
import.meta.hot.data.root = createRoot(elem);
|
|
31
|
+
}
|
|
32
|
+
import.meta.hot.data.root.render(app);
|
|
33
|
+
} else {
|
|
34
|
+
// Produksi: PWA_Shell (Requirement 8.1) — registrasi service worker & inject
|
|
35
|
+
// link manifest hanya di sini agar asset statis di-cache untuk instalasi PWA
|
|
36
|
+
// tanpa merusak HMR saat dev. Manifest di-inject runtime agar URL tetap
|
|
37
|
+
// `/manifest.json` (tidak di-rewrite bundler menjadi hashed asset), disajikan
|
|
38
|
+
// route server.
|
|
39
|
+
createRoot(elem).render(app);
|
|
40
|
+
|
|
41
|
+
const manifestLink = document.createElement("link");
|
|
42
|
+
manifestLink.rel = "manifest";
|
|
43
|
+
manifestLink.href = "/manifest.json";
|
|
44
|
+
document.head.appendChild(manifestLink);
|
|
45
|
+
|
|
46
|
+
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
|
47
|
+
navigator.serviceWorker.register("/sw.js").catch(() => {
|
|
48
|
+
// Gagal registrasi SW tidak menghalangi aplikasi.
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test logika murni referensi `@file` (src/hooks/useMention.ts).
|
|
3
|
+
*
|
|
4
|
+
* `bun test` tidak menyediakan DOM, sehingga yang diuji adalah
|
|
5
|
+
* `activeMention()` (deteksi token @query pada posisi kursor) dan
|
|
6
|
+
* `applyMention()` (substitusi token saat saran dipilih) — pola yang sama
|
|
7
|
+
* dengan test `useTheme.ts` / `useRouter.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { activeMention, applyMention } from "../useMention";
|
|
11
|
+
|
|
12
|
+
describe("activeMention", () => {
|
|
13
|
+
test("token @ aktif di akhir input", () => {
|
|
14
|
+
expect(activeMention("lihat @", 7)).toEqual({ query: "", start: 6, end: 7 });
|
|
15
|
+
expect(activeMention("lihat @sr", 9)).toEqual({ query: "sr", start: 6, end: 9 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("kursor di tengah token", () => {
|
|
19
|
+
// caret 6 pada "@src/app" -> token "@src/a"
|
|
20
|
+
expect(activeMention("@src/app", 6)).toEqual({ query: "src/a", start: 0, end: 6 });
|
|
21
|
+
// caret 8 -> token "@src/app"
|
|
22
|
+
expect(activeMention("@src/app", 8)).toEqual({ query: "src/app", start: 0, end: 8 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("bukan mention: tanpa @, @ tertutup spasi, atau kursor setelah spasi", () => {
|
|
26
|
+
expect(activeMention("halo", 4)).toBeNull();
|
|
27
|
+
expect(activeMention("@src done", 9)).toBeNull(); // spasi menutup token
|
|
28
|
+
expect(activeMention("a @ b", 5)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("@ yang menempel kata lain (email) bukan mention", () => {
|
|
32
|
+
expect(activeMention("user@mail", 9)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("spasi di dalam path menghentikan token", () => {
|
|
36
|
+
// kursor tepat sebelum spasi masih aktif
|
|
37
|
+
expect(activeMention("@src/ app", 5)).toEqual({ query: "src/", start: 0, end: 5 });
|
|
38
|
+
// setelah spasi -> tidak aktif
|
|
39
|
+
expect(activeMention("@src/ app", 9)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("properti — kursor di luar rentang wajar", () => {
|
|
43
|
+
expect(activeMention("abc", -1)).toBeNull();
|
|
44
|
+
expect(activeMention("abc", 10)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("applyMention", () => {
|
|
49
|
+
test("mengganti token @query dengan @path di posisi tepat", () => {
|
|
50
|
+
const range = activeMention("lihat @sr", 9);
|
|
51
|
+
if (!range) throw new Error("range null");
|
|
52
|
+
expect(applyMention("lihat @sr", range, "src/App.tsx")).toBe("lihat @src/App.tsx");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("menambah spasi penutup bila karakter berikut non-pemisah", () => {
|
|
56
|
+
// range.end = 8 tepat sebelum spasi? tidak — pakai teks tanpa spasi:
|
|
57
|
+
const t2 = "@src/app";
|
|
58
|
+
const range2 = activeMention(t2, 8);
|
|
59
|
+
if (!range2) throw new Error("range null");
|
|
60
|
+
expect(applyMention(t2, range2, "src/app.tsx")).toBe("@src/app.tsx");
|
|
61
|
+
// Suffix spasi terjadi bila token disusul karakter non-pemisah:
|
|
62
|
+
// "@sr|c x" (kursor di |, end=3) -> ganti "@sr" -> "@s/x.ts" + " c x".
|
|
63
|
+
const range = { query: "sr", start: 0, end: 3 };
|
|
64
|
+
expect(applyMention("@src x", range, "s/x.ts")).toBe("@s/x.ts c x");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tanpa spasi ekstra bila di akhir teks", () => {
|
|
68
|
+
const range = activeMention("@a", 2);
|
|
69
|
+
if (!range) throw new Error("range null");
|
|
70
|
+
expect(applyMention("@a", range, "a.ts")).toBe("@a.ts");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("sisa teks setelah token tetap utuh", () => {
|
|
74
|
+
const text = "@sr dan sisa";
|
|
75
|
+
const range = activeMention(text, 3);
|
|
76
|
+
if (!range) throw new Error("range null");
|
|
77
|
+
expect(applyMention(text, range, "src/x.ts")).toBe("@src/x.ts dan sisa");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test `useResyncOnReconnect` di React sungguhan (tanpa DOM, renderer minimal
|
|
3
|
+
* dari `react-reconciler` — pola yang sama dengan `useSmoothText.react.test`).
|
|
4
|
+
*/
|
|
5
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { act, createElement, StrictMode } from "react";
|
|
7
|
+
import Reconciler from "react-reconciler";
|
|
8
|
+
import { useResyncOnReconnect } from "../useResyncOnReconnect";
|
|
9
|
+
import type { WsConnectionStatus } from "../useWebSocket";
|
|
10
|
+
|
|
11
|
+
const g = globalThis as Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
const reconciler = Reconciler({
|
|
14
|
+
supportsMutation: true,
|
|
15
|
+
isPrimaryRenderer: false,
|
|
16
|
+
createInstance: () => ({}),
|
|
17
|
+
createTextInstance: () => ({}),
|
|
18
|
+
appendInitialChild: () => {},
|
|
19
|
+
appendChild: () => {},
|
|
20
|
+
appendChildToContainer: () => {},
|
|
21
|
+
insertBefore: () => {},
|
|
22
|
+
insertInContainerBefore: () => {},
|
|
23
|
+
removeChild: () => {},
|
|
24
|
+
removeChildFromContainer: () => {},
|
|
25
|
+
commitTextUpdate: () => {},
|
|
26
|
+
commitUpdate: () => {},
|
|
27
|
+
finalizeInitialChildren: () => false,
|
|
28
|
+
shouldSetTextContent: () => false,
|
|
29
|
+
getRootHostContext: () => ({}),
|
|
30
|
+
getChildHostContext: () => ({}),
|
|
31
|
+
getPublicInstance: (i: unknown) => i,
|
|
32
|
+
prepareForCommit: () => null,
|
|
33
|
+
resetAfterCommit: () => {},
|
|
34
|
+
preparePortalMount: () => {},
|
|
35
|
+
clearContainer: () => {},
|
|
36
|
+
scheduleTimeout: setTimeout,
|
|
37
|
+
cancelTimeout: clearTimeout,
|
|
38
|
+
noTimeout: -1,
|
|
39
|
+
getCurrentUpdatePriority: () => 32,
|
|
40
|
+
setCurrentUpdatePriority: () => {},
|
|
41
|
+
resolveUpdatePriority: () => 32,
|
|
42
|
+
getInstanceFromNode: () => null,
|
|
43
|
+
beforeActiveInstanceBlur: () => {},
|
|
44
|
+
afterActiveInstanceBlur: () => {},
|
|
45
|
+
prepareScopeUpdate: () => {},
|
|
46
|
+
getInstanceFromScope: () => null,
|
|
47
|
+
detachDeletedInstance: () => {},
|
|
48
|
+
maySuspendCommit: () => false,
|
|
49
|
+
preloadInstance: () => true,
|
|
50
|
+
startSuspendingCommit: () => {},
|
|
51
|
+
suspendInstance: () => {},
|
|
52
|
+
waitForCommitToBeReady: () => null,
|
|
53
|
+
NotPendingTransition: null,
|
|
54
|
+
HostTransitionContext: { $$typeof: Symbol.for("react.context"), _currentValue: null },
|
|
55
|
+
resetFormInstance: () => {},
|
|
56
|
+
requestPostPaintCallback: () => {},
|
|
57
|
+
shouldAttemptEagerTransition: () => false,
|
|
58
|
+
trackSchedulerEvent: () => {},
|
|
59
|
+
resolveEventType: () => null,
|
|
60
|
+
resolveEventTimeStamp: () => -1.1,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function mount() {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
function Probe({ status }: { status: WsConnectionStatus }) {
|
|
66
|
+
useResyncOnReconnect(status, () => {
|
|
67
|
+
calls += 1;
|
|
68
|
+
});
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const root = reconciler.createContainer(
|
|
72
|
+
{},
|
|
73
|
+
1,
|
|
74
|
+
null,
|
|
75
|
+
false,
|
|
76
|
+
null,
|
|
77
|
+
"",
|
|
78
|
+
() => {},
|
|
79
|
+
() => {},
|
|
80
|
+
() => {},
|
|
81
|
+
null,
|
|
82
|
+
);
|
|
83
|
+
const render = (status: WsConnectionStatus) =>
|
|
84
|
+
act(() => {
|
|
85
|
+
reconciler.updateContainer(
|
|
86
|
+
createElement(StrictMode, null, createElement(Probe, { status })),
|
|
87
|
+
root,
|
|
88
|
+
null,
|
|
89
|
+
null,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
return { render, calls: () => calls };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
g.IS_REACT_ACT_ENVIRONMENT = true;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("useResyncOnReconnect", () => {
|
|
100
|
+
test("koneksi pertama TIDAK memicu resync", () => {
|
|
101
|
+
const h = mount();
|
|
102
|
+
h.render("connecting");
|
|
103
|
+
h.render("connected");
|
|
104
|
+
expect(h.calls()).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("tersambung ulang setelah putus -> resync sekali", () => {
|
|
108
|
+
const h = mount();
|
|
109
|
+
h.render("connecting");
|
|
110
|
+
h.render("connected");
|
|
111
|
+
h.render("reconnecting");
|
|
112
|
+
h.render("connected");
|
|
113
|
+
expect(h.calls()).toBe(1);
|
|
114
|
+
// Tetap terhubung -> tidak berulang.
|
|
115
|
+
h.render("connected");
|
|
116
|
+
expect(h.calls()).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("setiap putus-sambung memicu resync", () => {
|
|
120
|
+
const h = mount();
|
|
121
|
+
h.render("connected");
|
|
122
|
+
for (let i = 0; i < 3; i++) {
|
|
123
|
+
h.render("reconnecting");
|
|
124
|
+
h.render("connected");
|
|
125
|
+
}
|
|
126
|
+
expect(h.calls()).toBe(3);
|
|
127
|
+
});
|
|
128
|
+
});
|