feat: support multiple Honcho instances (closes #2)
Replace single localStorage config with a named-instance store
({ instances: Instance[], activeId }). Adds an instances manager on
the settings page (list / add / edit / delete / activate) and a
sidebar switcher for quick swaps. Existing single-config users are
migrated transparently on first load.
Switching or deleting an instance clears the TanStack Query cache so
data from another deployment never bleeds into the active view.
This commit is contained in:
178
packages/web/src/components/settings/InstancesManager.tsx
Normal file
178
packages/web/src/components/settings/InstancesManager.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { SettingsForm } from "@/components/settings/SettingsForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };
|
||||
|
||||
interface InstancesManagerProps {
|
||||
onActivated?: () => void;
|
||||
}
|
||||
|
||||
export function InstancesManager({ onActivated }: InstancesManagerProps) {
|
||||
const { instances, activeId, activate, remove } = useInstances();
|
||||
const [mode, setMode] = useState<Mode>({ kind: "list" });
|
||||
|
||||
if (mode.kind === "create") {
|
||||
return (
|
||||
<SettingsForm
|
||||
instance={null}
|
||||
onSaved={() => {
|
||||
setMode({ kind: "list" });
|
||||
onActivated?.();
|
||||
}}
|
||||
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
|
||||
hideCancel={instances.length === 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === "edit") {
|
||||
const target = instances.find((i) => i.id === mode.id);
|
||||
if (!target) return null;
|
||||
return (
|
||||
<SettingsForm
|
||||
instance={target}
|
||||
onSaved={() => setMode({ kind: "list" })}
|
||||
onCancel={() => setMode({ kind: "list" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (instances.length === 0) {
|
||||
return (
|
||||
<SettingsForm
|
||||
instance={null}
|
||||
onSaved={() => onActivated?.()}
|
||||
hideCancel
|
||||
submitLabel="Save Connection"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{instances.map((inst) => (
|
||||
<InstanceRow
|
||||
key={inst.id}
|
||||
instance={inst}
|
||||
active={inst.id === activeId}
|
||||
onActivate={() => {
|
||||
activate(inst.id);
|
||||
onActivated?.();
|
||||
}}
|
||||
onEdit={() => setMode({ kind: "edit", id: inst.id })}
|
||||
onDelete={() => remove(inst.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMode({ kind: "create" })}
|
||||
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={1.5} />
|
||||
Add another instance
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InstanceRowProps {
|
||||
instance: Instance;
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className="rounded-xl p-3 flex items-center gap-3"
|
||||
style={{
|
||||
background: active ? "var(--accent-dim)" : "var(--bg-2)",
|
||||
border: `1px solid ${active ? "var(--accent-border)" : "var(--border)"}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onActivate}
|
||||
className="flex-1 flex items-center gap-3 text-left"
|
||||
disabled={active}
|
||||
title={active ? "Active instance" : "Switch to this instance"}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: active ? "var(--accent)" : "var(--surface)",
|
||||
color: active ? "white" : "var(--text-3)",
|
||||
}}
|
||||
>
|
||||
{active ? (
|
||||
<Check className="w-4 h-4" strokeWidth={2} />
|
||||
) : (
|
||||
<Server className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: active ? "var(--accent-text)" : "var(--text-1)" }}
|
||||
>
|
||||
{instance.name}
|
||||
</p>
|
||||
<Muted className="text-xs font-mono truncate">
|
||||
{instance.baseUrl.replace(/^https?:\/\//, "")}
|
||||
</Muted>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
{confirmingDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
className="text-xs font-medium px-2 py-1 rounded-md"
|
||||
style={{ color: COLOR.destructive, border: `1px solid ${COLOR.destructive}` }}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user