refactor(web): update workspace components for workspace model
ProfileSwitcher uses workspaces; create-workspace-dialog, empty-state, sidebar updated.
This commit is contained in:
parent
974ba61b48
commit
1adb7b926b
@ -351,7 +351,7 @@ export function Sidebar({
|
||||
const [mainMemory, setMainMemory] = useState<string | null>(null);
|
||||
const [dailyLogs, setDailyLogs] = useState<MemoryFile[]>([]);
|
||||
const [workspaceTree, setWorkspaceTree] = useState<TreeNode[]>([]);
|
||||
const [activeProfile, setActiveProfile] = useState("default");
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toggleSection = (section: SidebarSection) => {
|
||||
@ -368,19 +368,19 @@ export function Sidebar({
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes, profilesRes] = await Promise.all([
|
||||
const [webSessionsRes, skillsRes, memoriesRes, workspaceRes, workspaceListRes] = await Promise.all([
|
||||
fetch("/api/web-sessions").then((r) => r.json()),
|
||||
fetch("/api/skills").then((r) => r.json()),
|
||||
fetch("/api/memories").then((r) => r.json()),
|
||||
fetch("/api/workspace/tree").then((r) => r.json()).catch(() => ({ tree: [] })),
|
||||
fetch("/api/profiles").then((r) => r.json()).catch(() => ({ activeProfile: "default" })),
|
||||
fetch("/api/workspace/list").then((r) => r.json()).catch(() => ({ activeWorkspace: null })),
|
||||
]);
|
||||
setWebSessions(webSessionsRes.sessions ?? []);
|
||||
setSkills(skillsRes.skills ?? []);
|
||||
setMainMemory(memoriesRes.mainMemory ?? null);
|
||||
setDailyLogs(memoriesRes.dailyLogs ?? []);
|
||||
setWorkspaceTree(workspaceRes.tree ?? []);
|
||||
setActiveProfile(String(profilesRes.activeProfile || "default"));
|
||||
setActiveWorkspace((workspaceListRes.activeWorkspace ?? null) as string | null);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sidebar data:", err);
|
||||
} finally {
|
||||
@ -428,7 +428,9 @@ export function Sidebar({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Profile: {activeProfile}</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Workspace: {activeWorkspace ?? "none"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { DirectoryPickerModal } from "./directory-picker-modal";
|
||||
|
||||
type CreateWorkspaceDialogProps = {
|
||||
isOpen: boolean;
|
||||
@ -16,17 +15,9 @@ function shortenPath(p: string): string {
|
||||
.replace(/^[A-Za-z]:[/\\]Users[/\\][^/\\]+/, "~");
|
||||
}
|
||||
|
||||
function pathBasename(p: string): string {
|
||||
return p.replaceAll("\\", "/").split("/").pop() || p;
|
||||
}
|
||||
|
||||
export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWorkspaceDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [customPath, setCustomPath] = useState("");
|
||||
const [useCustomPath, setUseCustomPath] = useState(false);
|
||||
const [showDirPicker, setShowDirPicker] = useState(false);
|
||||
const [workspaceName, setWorkspaceName] = useState("");
|
||||
const [seedBootstrap, setSeedBootstrap] = useState(true);
|
||||
const [copyConfigAuth, setCopyConfigAuth] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ workspaceDir: string; seededFiles: string[] } | null>(null);
|
||||
@ -36,30 +27,26 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
// Focus input on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setProfileName("");
|
||||
setCustomPath("");
|
||||
setUseCustomPath(false);
|
||||
setShowDirPicker(false);
|
||||
setCopyConfigAuth(true);
|
||||
setWorkspaceName("");
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape (only if dir picker is not open)
|
||||
// Close on Escape.
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && !showDirPicker) {onClose();}
|
||||
if (e.key === "Escape") {onClose();}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}
|
||||
}, [isOpen, onClose, showDirPicker]);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = profileName.trim();
|
||||
const name = workspaceName.trim();
|
||||
if (!name) {
|
||||
setError("Please enter a workspace name.");
|
||||
return;
|
||||
@ -74,13 +61,9 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
profile: name,
|
||||
workspace: name,
|
||||
seedBootstrap,
|
||||
copyConfigAuth,
|
||||
};
|
||||
if (useCustomPath && customPath.trim()) {
|
||||
body.path = customPath.trim();
|
||||
}
|
||||
|
||||
const res = await fetch("/api/workspace/init", {
|
||||
method: "POST",
|
||||
@ -196,9 +179,9 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={profileName}
|
||||
value={workspaceName}
|
||||
onChange={(e) => {
|
||||
setProfileName(e.target.value);
|
||||
setWorkspaceName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@ -216,91 +199,10 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
className="text-xs mt-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
This creates a new profile with its own workspace directory.
|
||||
This creates a workspace under ~/.openclaw-ironclaw/workspace-{"{name}"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom path toggle */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setUseCustomPath(!useCustomPath)}
|
||||
className="flex items-center gap-2 text-xs transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${useCustomPath ? "rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Custom directory path
|
||||
</button>
|
||||
|
||||
{useCustomPath && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{customPath ? (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: "rgba(245, 158, 11, 0.12)", color: "#f59e0b" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: "var(--color-text)" }}>
|
||||
{pathBasename(customPath)}
|
||||
</p>
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--color-text-muted)" }} title={customPath}>
|
||||
{shortenPath(customPath)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="px-2 py-1 text-xs rounded-md transition-colors hover:opacity-80"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomPath("")}
|
||||
className="p-1 rounded-md transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDirPicker(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-3 rounded-lg text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border-strong)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</svg>
|
||||
Browse for a directory...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bootstrap toggle */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@ -317,22 +219,6 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
Seed bootstrap files and workspace database
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyConfigAuth}
|
||||
onChange={(e) => setCopyConfigAuth(e.target.checked)}
|
||||
className="rounded"
|
||||
style={{ accentColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
Copy Ironclaw config and auth profiles
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="text-sm px-3 py-2 rounded-lg"
|
||||
@ -375,7 +261,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !profileName.trim()}
|
||||
disabled={creating || !workspaceName.trim()}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
@ -388,14 +274,6 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory picker modal */}
|
||||
<DirectoryPickerModal
|
||||
open={showDirPicker}
|
||||
onClose={() => setShowDirPicker(false)}
|
||||
onSelect={(path) => setCustomPath(path)}
|
||||
startDir="~"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -136,7 +136,7 @@ export function EmptyState({
|
||||
>
|
||||
{expectedPath
|
||||
? shortenPath(expectedPath)
|
||||
: "~/.openclaw/workspace"}
|
||||
: "~/.openclaw-ironclaw/workspace-<name>"}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
export type ProfileInfo = {
|
||||
export type WorkspaceInfo = {
|
||||
name: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string | null;
|
||||
@ -13,16 +13,16 @@ export type ProfileInfo = {
|
||||
export type ProfileSwitcherTriggerProps = {
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
activeProfile: string;
|
||||
activeWorkspace: string | null;
|
||||
switching: boolean;
|
||||
};
|
||||
|
||||
type ProfileSwitcherProps = {
|
||||
onProfileSwitch?: () => void;
|
||||
onWorkspaceDelete?: (profileName: string) => void;
|
||||
onWorkspaceSwitch?: () => void;
|
||||
onWorkspaceDelete?: (workspaceName: string) => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
/** Parent-tracked active profile -- triggers a re-fetch when it changes (e.g. after workspace creation). */
|
||||
activeProfileHint?: string | null;
|
||||
/** Parent-tracked active workspace, used to trigger refetches after changes. */
|
||||
activeWorkspaceHint?: string | null;
|
||||
/** When set, this renders instead of the default button; dropdown still opens below. */
|
||||
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
|
||||
};
|
||||
@ -35,34 +35,42 @@ function shortenPath(p: string): string {
|
||||
}
|
||||
|
||||
export function ProfileSwitcher({
|
||||
onProfileSwitch,
|
||||
onWorkspaceSwitch,
|
||||
onWorkspaceDelete,
|
||||
onCreateWorkspace,
|
||||
activeProfileHint,
|
||||
activeWorkspaceHint,
|
||||
trigger,
|
||||
}: ProfileSwitcherProps) {
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [activeProfile, setActiveProfile] = useState("default");
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [deletingProfile, setDeletingProfile] = useState<string | null>(null);
|
||||
const [deletingWorkspace, setDeletingWorkspace] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchProfiles = useCallback(async () => {
|
||||
const fetchWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/profiles");
|
||||
const res = await fetch("/api/workspace/list");
|
||||
const data = await res.json();
|
||||
setProfiles(data.profiles ?? []);
|
||||
setActiveProfile(data.activeProfile ?? "default");
|
||||
const nextWorkspaces = ((data.workspaces ?? data.profiles ?? []) as WorkspaceInfo[])
|
||||
.filter((workspace) => Boolean(workspace.workspaceDir));
|
||||
const nextActiveWorkspace =
|
||||
(data.activeWorkspace ?? data.activeProfile ?? null) as string | null;
|
||||
const activeFromList =
|
||||
nextActiveWorkspace && nextWorkspaces.some((workspace) => workspace.name === nextActiveWorkspace)
|
||||
? nextActiveWorkspace
|
||||
: (nextWorkspaces.find((workspace) => workspace.isActive)?.name ?? nextWorkspaces[0]?.name ?? null);
|
||||
setWorkspaces(nextWorkspaces);
|
||||
setActiveWorkspace(activeFromList);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProfiles();
|
||||
}, [fetchProfiles, activeProfileHint]);
|
||||
void fetchWorkspaces();
|
||||
}, [fetchWorkspaces, activeWorkspaceHint]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
@ -77,75 +85,74 @@ export function ProfileSwitcher({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSwitch = async (profileName: string) => {
|
||||
if (profileName === activeProfile) {
|
||||
const handleSwitch = async (workspaceName: string) => {
|
||||
if (workspaceName === activeWorkspace) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
setSwitching(true);
|
||||
try {
|
||||
const res = await fetch("/api/profiles/switch", {
|
||||
const res = await fetch("/api/workspace/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: profileName }),
|
||||
body: JSON.stringify({ workspace: workspaceName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setActiveProfile(data.activeProfile ?? "default");
|
||||
onProfileSwitch?.();
|
||||
void fetchProfiles();
|
||||
setActiveWorkspace((data.activeWorkspace ?? data.activeProfile ?? null) as string | null);
|
||||
onWorkspaceSwitch?.();
|
||||
void fetchWorkspaces();
|
||||
} else {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setActionError(data.error ?? "Failed to switch profile.");
|
||||
setActionError(data.error ?? "Failed to switch workspace.");
|
||||
}
|
||||
} catch {
|
||||
setActionError("Failed to switch profile.");
|
||||
setActionError("Failed to switch workspace.");
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = async (profileName: string) => {
|
||||
const target = profiles.find((p) => p.name === profileName);
|
||||
const handleDeleteWorkspace = async (workspaceName: string) => {
|
||||
const target = workspaces.find((workspace) => workspace.name === workspaceName);
|
||||
if (!target?.workspaceDir) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
`Delete workspace for profile "${profileName}"?\n\nThis runs openclaw --profile ${profileName} workspace delete.`,
|
||||
`Delete workspace "${workspaceName}"?\n\nThis permanently removes:\n${shortenPath(target.workspaceDir)}\n\nThis cannot be undone.`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError(null);
|
||||
setDeletingProfile(profileName);
|
||||
setDeletingWorkspace(workspaceName);
|
||||
try {
|
||||
const res = await fetch("/api/workspace/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile: profileName }),
|
||||
body: JSON.stringify({ workspace: workspaceName }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setActionError(data.error ?? `Failed to delete workspace for profile '${profileName}'.`);
|
||||
setActionError(data.error ?? `Failed to delete workspace '${workspaceName}'.`);
|
||||
return;
|
||||
}
|
||||
if (profileName === activeProfile) {
|
||||
onProfileSwitch?.();
|
||||
if (workspaceName === activeWorkspace) {
|
||||
onWorkspaceSwitch?.();
|
||||
}
|
||||
onWorkspaceDelete?.(profileName);
|
||||
await fetchProfiles();
|
||||
onWorkspaceDelete?.(workspaceName);
|
||||
await fetchWorkspaces();
|
||||
} catch {
|
||||
setActionError(`Failed to delete workspace for profile '${profileName}'.`);
|
||||
setActionError(`Failed to delete workspace '${workspaceName}'.`);
|
||||
} finally {
|
||||
setDeletingProfile(null);
|
||||
setDeletingWorkspace(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't show the switcher if there's only one profile and no way to create more
|
||||
const showSwitcher = profiles.length > 0;
|
||||
const showSwitcher = workspaces.length > 0;
|
||||
const handleToggle = () => {
|
||||
if (showSwitcher) { setIsOpen((o) => !o); }
|
||||
};
|
||||
@ -161,7 +168,7 @@ export function ProfileSwitcher({
|
||||
trigger({
|
||||
isOpen,
|
||||
onClick: handleToggle,
|
||||
activeProfile,
|
||||
activeWorkspace,
|
||||
switching,
|
||||
})
|
||||
) : (
|
||||
@ -170,14 +177,14 @@ export function ProfileSwitcher({
|
||||
disabled={switching}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
title="Switch workspace profile"
|
||||
title="Switch workspace"
|
||||
>
|
||||
{/* Workspace icon */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{activeProfile === "default" ? "Default" : activeProfile}
|
||||
{activeWorkspace ?? "No workspace"}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
||||
@ -207,18 +214,18 @@ export function ProfileSwitcher({
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Workspace Profiles
|
||||
Workspaces
|
||||
</div>
|
||||
|
||||
{/* Profile list */}
|
||||
{/* Workspace list */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{profiles.map((p) => {
|
||||
const isCurrent = p.name === activeProfile;
|
||||
{workspaces.map((workspace) => {
|
||||
const isCurrent = workspace.name === activeWorkspace;
|
||||
return (
|
||||
<div key={p.name} className="flex items-center gap-1 px-1.5 py-0.5">
|
||||
<div key={workspace.name} className="flex items-center gap-1 px-1.5 py-0.5">
|
||||
<button
|
||||
onClick={() => void handleSwitch(p.name)}
|
||||
disabled={switching || !!deletingProfile}
|
||||
onClick={() => void handleSwitch(workspace.name)}
|
||||
disabled={switching || !!deletingWorkspace}
|
||||
className="flex-1 min-w-0 flex items-center gap-2 px-1.5 py-1.5 rounded text-left text-sm transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
@ -234,15 +241,15 @@ export function ProfileSwitcher({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">
|
||||
{p.name === "default" ? "Default" : p.name}
|
||||
{workspace.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs truncate mt-0.5"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{p.workspaceDir
|
||||
? shortenPath(p.workspaceDir)
|
||||
{workspace.workspaceDir
|
||||
? shortenPath(workspace.workspaceDir)
|
||||
: "No workspace yet"}
|
||||
</div>
|
||||
</div>
|
||||
@ -260,14 +267,14 @@ export function ProfileSwitcher({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{p.workspaceDir && (
|
||||
{workspace.workspaceDir && (
|
||||
<button
|
||||
onClick={() => void handleDeleteWorkspace(p.name)}
|
||||
disabled={switching || !!deletingProfile}
|
||||
title={`Delete workspace for ${p.name}`}
|
||||
onClick={() => void handleDeleteWorkspace(workspace.name)}
|
||||
disabled={switching || !!deletingWorkspace}
|
||||
title={`Delete workspace ${workspace.name}`}
|
||||
className="p-1.5 rounded transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
style={{
|
||||
color: deletingProfile === p.name
|
||||
color: deletingWorkspace === workspace.name
|
||||
? "var(--color-text-muted)"
|
||||
: "var(--color-error)",
|
||||
}}
|
||||
|
||||
@ -48,10 +48,10 @@ type WorkspaceSidebarProps = {
|
||||
onToggleHidden?: () => void;
|
||||
/** Called when the user clicks the collapse/hide sidebar button. */
|
||||
onCollapse?: () => void;
|
||||
/** Active profile hint used by the profile switcher. */
|
||||
activeProfile?: string | null;
|
||||
/** Called after profile switches or workspace creation so parent can refresh state. */
|
||||
onProfileChanged?: () => void;
|
||||
/** Active workspace hint used by the switcher. */
|
||||
activeWorkspace?: string | null;
|
||||
/** Called after workspace switches or workspace creation so parent can refresh state. */
|
||||
onWorkspaceChanged?: () => void;
|
||||
};
|
||||
|
||||
function HomeIcon() {
|
||||
@ -431,8 +431,8 @@ export function WorkspaceSidebar({
|
||||
onToggleHidden,
|
||||
width: widthProp,
|
||||
onCollapse,
|
||||
activeProfile,
|
||||
onProfileChanged,
|
||||
activeWorkspace,
|
||||
onWorkspaceChanged,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
const width = mobile ? "280px" : (widthProp ?? 260);
|
||||
@ -519,28 +519,28 @@ export function WorkspaceSidebar({
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<ProfileSwitcher
|
||||
activeProfileHint={activeProfile ?? null}
|
||||
onProfileSwitch={() => {
|
||||
onProfileChanged?.();
|
||||
activeWorkspaceHint={activeWorkspace ?? null}
|
||||
onWorkspaceSwitch={() => {
|
||||
onWorkspaceChanged?.();
|
||||
}}
|
||||
onWorkspaceDelete={() => {
|
||||
onProfileChanged?.();
|
||||
onWorkspaceChanged?.();
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setCreateWorkspaceOpen(true);
|
||||
}}
|
||||
trigger={({ onClick, activeProfile: profileName, switching }) => (
|
||||
trigger={({ onClick, activeWorkspace: workspaceName, switching }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={switching}
|
||||
className="text-[11px] flex items-center gap-1 truncate w-full transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Switch profile"
|
||||
title="Switch workspace"
|
||||
>
|
||||
<span>Profile</span>
|
||||
<span>Workspace</span>
|
||||
<span className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-300">
|
||||
{profileName || "default"}
|
||||
{workspaceName || "-"}
|
||||
</span>
|
||||
<svg
|
||||
width="10"
|
||||
@ -670,7 +670,7 @@ export function WorkspaceSidebar({
|
||||
isOpen={createWorkspaceOpen}
|
||||
onClose={() => setCreateWorkspaceOpen(false)}
|
||||
onCreated={() => {
|
||||
onProfileChanged?.();
|
||||
onWorkspaceChanged?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@ -689,7 +689,7 @@ export function WorkspaceSidebar({
|
||||
isOpen={createWorkspaceOpen}
|
||||
onClose={() => setCreateWorkspaceOpen(false)}
|
||||
onCreated={() => {
|
||||
onProfileChanged?.();
|
||||
onWorkspaceChanged?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user