Files
kandev/apps/web/components/settings/repository-card.tsx
T
2026-08-26 11:09:00 +00:00

531 lines
18 KiB
TypeScript

"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconGitBranch, IconTrash, IconX } from "@tabler/icons-react";
import { CardContent } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { UnsavedChangesBadge } from "@/components/settings/unsaved-indicator";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { RepositoryPreview } from "@/components/settings/repository-card-preview";
import { EditableCard } from "@/components/settings/editable-card";
import { RepositoryBranchTemplateHelp } from "@/components/settings/repository-branch-template-help";
import { DeleteRepositoryDialog } from "@/components/settings/repository-delete-dialog";
import { CopyFilesField } from "@/components/settings/repository-copy-files-help";
import { RepositoryCustomScripts } from "@/components/settings/repository-custom-scripts";
import { RepositoryBranchPolicies } from "@/components/settings/repository-branch-policies";
import {
RepositorySecretBindings,
validateRepositorySecretBindings,
} from "@/components/settings/repository-secret-bindings";
import { getRepositoryActiveSessionCountAction } from "@/app/actions/workspaces";
import type { Repository, RepositoryScript } from "@/lib/types/http";
import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template";
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
/**
* The environment variable the dev script reads to learn its allocated port. It
* is an identifier the user types into a shell command verbatim, so it travels
* as an interpolated value rather than sitting inside the message.
*/
const DEV_SCRIPT_PORT_VAR = "$PORT";
/**
* Shell script samples. Each is code the user edits and the executor runs
* verbatim, so none of it is copy — the same call as `DEFAULT_DOCKERFILE` in the
* executor profile editor. Hoisted out of the JSX so the intent is explicit
* rather than resting on a guard exclusion pattern.
*/
const SETUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual setup you need";
const CLEANUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual clean up you need";
const DEV_SCRIPT_PLACEHOLDER = "#!/bin/bash\nnpm run dev -- --port $PORT";
type RepoFieldsBaseProps = {
repositoryId: string;
onUpdate: (repoId: string, updates: Partial<Repository>) => void;
};
type RepositoryBasicFieldsProps = RepoFieldsBaseProps & {
savedRepository?: RepositoryWithScripts;
repositoryName: string;
repositoryLocalPath: string;
sourceType: string;
worktreeBranchTemplate: string;
pullBeforeWorktree: boolean;
};
function RepositoryBasicFields({
repositoryId,
savedRepository,
onUpdate,
repositoryName,
repositoryLocalPath,
sourceType,
worktreeBranchTemplate,
pullBeforeWorktree,
}: RepositoryBasicFieldsProps) {
const { t } = useTranslation();
return (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("workspaces:repositoryName")}</Label>
{/* Both placeholders are value shapes — a git repository name and an
absolute filesystem path — so they stay in English. */}
<Input
value={repositoryName}
onChange={(e) => onUpdate(repositoryId, { name: e.target.value })}
placeholder="my-repo"
data-settings-dirty={repositoryName !== (savedRepository?.name ?? "")}
/>
</div>
<div className="space-y-2">
<Label>{t("workspaces:localPath")}</Label>
<Input
value={repositoryLocalPath}
onChange={(e) => onUpdate(repositoryId, { local_path: e.target.value })}
placeholder="/path/to/repository"
disabled={sourceType !== "local"}
data-settings-dirty={repositoryLocalPath !== (savedRepository?.local_path ?? "")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>{t("workspaces:worktreeBranchTemplate")}</Label>
<RepositoryBranchTemplateHelp />
</div>
<Input
value={worktreeBranchTemplate}
onChange={(e) => onUpdate(repositoryId, { worktree_branch_template: e.target.value })}
placeholder={defaultWorktreeBranchTemplate}
data-settings-dirty={
worktreeBranchTemplate !==
(savedRepository?.worktree_branch_template ?? defaultWorktreeBranchTemplate)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`repo-pull-before-${repositoryId}`}>{t("workspaces:worktreeSync")}</Label>
<div className="flex items-start gap-2 pt-2">
<Checkbox
id={`repo-pull-before-${repositoryId}`}
checked={pullBeforeWorktree}
onCheckedChange={(checked) =>
onUpdate(repositoryId, { pull_before_worktree: checked === true })
}
data-settings-dirty={
pullBeforeWorktree !== (savedRepository?.pull_before_worktree ?? true)
}
/>
<div className="space-y-1">
<Label
htmlFor={`repo-pull-before-${repositoryId}`}
className="text-sm text-muted-foreground cursor-pointer"
>
{t("workspaces:alwaysPullBeforeWorktree")}
</Label>
</div>
</div>
</div>
</div>
</>
);
}
type RepositoryScriptFieldsProps = RepoFieldsBaseProps & {
savedRepository?: RepositoryWithScripts;
setupScript: string;
cleanupScript: string;
devScript: string;
copyFiles: string;
};
function RepositoryScriptFields({
repositoryId,
savedRepository,
onUpdate,
setupScript,
cleanupScript,
devScript,
copyFiles,
}: RepositoryScriptFieldsProps) {
const { t } = useTranslation();
return (
<>
{/* Every script placeholder below is a shell sample — a shebang plus a
command the executor runs verbatim — so it is code, not copy. */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("workspaces:setupScript")}</Label>
<Textarea
value={setupScript}
onChange={(e) => onUpdate(repositoryId, { setup_script: e.target.value })}
placeholder={SETUP_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={setupScript !== (savedRepository?.setup_script ?? "")}
/>
<p className="text-xs text-muted-foreground">{t("workspaces:setupScriptHelp")}</p>
</div>
<div className="space-y-2">
<Label>{t("workspaces:cleanupScript")}</Label>
<Textarea
value={cleanupScript}
onChange={(e) => onUpdate(repositoryId, { cleanup_script: e.target.value })}
placeholder={CLEANUP_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={cleanupScript !== (savedRepository?.cleanup_script ?? "")}
/>
<p className="text-xs text-muted-foreground">{t("workspaces:cleanupScriptHelp")}</p>
</div>
</div>
<div className="space-y-2">
<Label>{t("workspaces:devScript")}</Label>
<Textarea
value={devScript}
onChange={(e) => onUpdate(repositoryId, { dev_script: e.target.value })}
placeholder={DEV_SCRIPT_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={devScript !== (savedRepository?.dev_script ?? "")}
/>
<p className="text-xs text-muted-foreground">
<Trans i18nKey="workspaces:devScriptHelp" values={{ port: DEV_SCRIPT_PORT_VAR }}>
<code className="px-1 py-0.5 bg-muted rounded" />
</Trans>
</p>
</div>
<CopyFilesField
repositoryId={repositoryId}
copyFiles={copyFiles}
isDirty={copyFiles !== (savedRepository?.copy_files ?? "")}
onUpdate={onUpdate}
/>
</>
);
}
type RepositoryEditViewProps = {
repository: RepositoryWithScripts;
workspaceId: string;
savedRepository?: RepositoryWithScripts;
isDirty: boolean;
areScriptsDirty: boolean;
onUpdate: (repoId: string, updates: Partial<Repository>) => void;
onAddScript: (repoId: string) => void;
onUpdateScript: (repoId: string, scriptId: string, updates: Partial<RepositoryScript>) => void;
onDeleteScript: (repoId: string, scriptId: string) => void;
onOpenDelete: () => void;
deleteLoading: boolean;
close: () => void;
};
function RepositoryEditView({
repository,
workspaceId,
savedRepository,
isDirty,
areScriptsDirty,
onUpdate,
onAddScript,
onUpdateScript,
onDeleteScript,
onOpenDelete,
deleteLoading,
close,
}: RepositoryEditViewProps) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isDirty}>
<CardContent className="pt-6">
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
<Label className="flex items-center gap-2">
<span>{t("workspaces:repository")}</span>
{isDirty && <UnsavedChangesBadge />}
</Label>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="cursor-pointer"
aria-label={t("workspaces:closeRepositoryEditor")}
onClick={close}
>
<IconX className="h-4 w-4" />
</Button>
</div>
<RepositoryBasicFields
repositoryId={repository.id}
savedRepository={savedRepository}
onUpdate={onUpdate}
repositoryName={repository.name ?? ""}
repositoryLocalPath={repository.local_path ?? ""}
sourceType={repository.source_type}
worktreeBranchTemplate={
repository.worktree_branch_template ?? defaultWorktreeBranchTemplate
}
pullBeforeWorktree={repository.pull_before_worktree ?? true}
/>
<RepositoryBranchPolicies repository={repository} workspaceId={workspaceId} />
<RepositoryScriptFields
repositoryId={repository.id}
savedRepository={savedRepository}
onUpdate={onUpdate}
setupScript={repository.setup_script ?? ""}
cleanupScript={repository.cleanup_script ?? ""}
devScript={repository.dev_script ?? ""}
copyFiles={repository.copy_files ?? ""}
/>
<RepositorySecretBindings repository={repository} onUpdate={onUpdate} />
<RepositoryCustomScripts
repositoryId={repository.id}
scripts={repository.scripts}
savedScripts={savedRepository?.scripts}
areScriptsDirty={areScriptsDirty}
onAddScript={onAddScript}
onUpdateScript={onUpdateScript}
onDeleteScript={onDeleteScript}
/>
<div className="flex justify-end">
<Button
type="button"
variant="destructive"
onClick={onOpenDelete}
disabled={deleteLoading}
>
<IconTrash className="h-4 w-4 mr-2" />
{t("workspaces:deleteRepository")}
</Button>
</div>
</div>
</CardContent>
</SettingsCard>
);
}
function repositorySecretInvalidReason(
t: TFunction,
validation: ReturnType<typeof validateRepositorySecretBindings>,
) {
if (!validation) return undefined;
switch (validation.kind) {
case "key":
return t("workspaces:environmentSecretKeyInvalid");
case "duplicate":
return t("workspaces:environmentSecretKeyDuplicate", { key: validation.key });
case "reserved":
return t("workspaces:environmentSecretKeyReserved", { key: validation.key });
case "secret":
return t("workspaces:environmentSecretRequired");
}
}
type RepositoryCardProps = {
repository: RepositoryWithScripts;
workspaceId: string;
savedRepository?: RepositoryWithScripts;
isRepositoryDirty: boolean;
areScriptsDirty: boolean;
autoOpen?: boolean;
/** Repositories in the dedicated Improve Kandev workspace are read-only. */
readOnly?: boolean;
onUpdate: (repoId: string, updates: Partial<Repository>) => void;
onAddScript: (repoId: string) => void;
onUpdateScript: (repoId: string, scriptId: string, updates: Partial<RepositoryScript>) => void;
onDeleteScript: (repoId: string, scriptId: string) => void;
onSave: (repoId: string) => Promise<void>;
onDelete: (repoId: string) => Promise<void> | void;
};
function useRepositoryDelete(
repositoryId: string,
onDelete: (repoId: string) => Promise<void> | void,
onDeleted: () => void,
) {
const { toast } = useToast();
const { t } = useTranslation();
const [deleteOpen, setDeleteOpen] = useState(false);
const [activeSessionCount, setActiveSessionCount] = useState(0);
const [checkingCount, setCheckingCount] = useState(false);
const deleteRequest = useRequest(async () => {
await onDelete(repositoryId);
});
const handleOpenDelete = async () => {
// Reset count up-front so a stale value from a previous open can't flash
// the destructive button between dialog mount and the async fetch
// resolving with the fresh count.
setActiveSessionCount(0);
if (repositoryId.startsWith("temp-repo-")) {
setDeleteOpen(true);
return;
}
setCheckingCount(true);
try {
const { active_session_count } = await getRepositoryActiveSessionCountAction(repositoryId);
setActiveSessionCount(active_session_count);
setDeleteOpen(true);
} catch (error) {
toast({
title: t("workspaces:failedToCheckRepositorySessions"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
} finally {
setCheckingCount(false);
}
};
const handleDelete = async () => {
try {
await deleteRequest.run();
setDeleteOpen(false);
onDeleted();
} catch (error) {
toast({
title: t("workspaces:failedToDeleteRepository"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
};
return {
deleteOpen,
setDeleteOpen,
activeSessionCount,
handleOpenDelete,
handleDelete,
buttonLoading: deleteRequest.isLoading || checkingCount,
dialogDeleteLoading: deleteRequest.isLoading,
};
}
export function RepositoryCard({
repository,
workspaceId,
savedRepository,
isRepositoryDirty,
areScriptsDirty,
autoOpen = false,
readOnly = false,
onUpdate,
onAddScript,
onUpdateScript,
onDeleteScript,
onSave,
onDelete,
}: RepositoryCardProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(() => autoOpen);
const saveRequest = useRequest(() => onSave(repository.id));
const isDirty = isRepositoryDirty || areScriptsDirty;
const deleteState = useRepositoryDelete(repository.id, onDelete, () => setIsEditing(false));
const secretBindingValidation = validateRepositorySecretBindings(repository.secret_bindings);
const invalidReason = repositorySecretInvalidReason(t, secretBindingValidation);
const handleSave = async () => {
try {
await saveRequest.run();
} catch (error) {
toast({
title: t("workspaces:failedToSaveRepository"),
description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
throw error;
}
};
useSettingsSaveContributor({
id: `repository:${repository.id}`,
revision: JSON.stringify(repository),
isDirty,
canSave: !secretBindingValidation,
invalidReason,
save: handleSave,
discard: () => undefined,
});
// Read-only (dedicated Improve Kandev workspace): show the repository
// without edit/delete affordances.
if (readOnly) {
return (
<RepositoryPreview
repository={repository}
isDirty={false}
deleteLoading={false}
onOpenDelete={() => {}}
open={() => {}}
/>
);
}
return (
<>
<EditableCard
isEditing={isEditing}
historyId={`repo-${repository.id}`}
onOpen={() => setIsEditing(true)}
onClose={() => setIsEditing(false)}
renderEdit={({ close }) => (
<RepositoryEditView
repository={repository}
workspaceId={workspaceId}
savedRepository={savedRepository}
isDirty={isDirty}
areScriptsDirty={areScriptsDirty}
onUpdate={onUpdate}
onAddScript={onAddScript}
onUpdateScript={onUpdateScript}
onDeleteScript={onDeleteScript}
onOpenDelete={deleteState.handleOpenDelete}
deleteLoading={deleteState.buttonLoading}
close={close}
/>
)}
renderPreview={({ open }) => (
<RepositoryPreview
repository={repository}
isDirty={isDirty}
deleteLoading={deleteState.buttonLoading}
onOpenDelete={deleteState.handleOpenDelete}
open={open}
/>
)}
/>
<DeleteRepositoryDialog
open={deleteState.deleteOpen}
onOpenChange={deleteState.setDeleteOpen}
onDelete={deleteState.handleDelete}
activeSessionCount={deleteState.activeSessionCount}
deleteLoading={deleteState.dialogDeleteLoading}
/>
</>
);
}