openclaw/apps/web/app/components/workspace/workspace-sidebar.tsx

657 lines
18 KiB
TypeScript

"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
import { ProfileSwitcher } from "./profile-switcher";
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
import { UnicodeSpinner } from "../unicode-spinner";
/** Shape returned by /api/workspace/suggest-files */
type SuggestItem = {
name: string;
path: string;
type: "folder" | "file" | "document" | "database";
};
type WorkspaceSidebarProps = {
tree: TreeNode[];
activePath: string | null;
onSelect: (node: TreeNode) => void;
onRefresh: () => void;
orgName?: string;
loading?: boolean;
/** Current browse directory (absolute path), or null when in workspace mode. */
browseDir?: string | null;
/** Parent directory for ".." navigation. Null at filesystem root or when browsing is unavailable. */
parentDir?: string | null;
/** Navigate up one directory. */
onNavigateUp?: () => void;
/** Return to workspace mode from browse mode. */
onGoHome?: () => void;
/** Called when a file/folder is selected from the search dropdown. */
onFileSearchSelect?: (item: SuggestItem) => void;
/** Absolute path of the workspace root folder, used to render it as a special entry in browse mode. */
workspaceRoot?: string | null;
/** Navigate to the main chat / home panel. */
onGoToChat?: () => void;
/** Called when a tree node is dragged and dropped onto an external target (e.g. chat input). */
onExternalDrop?: (node: TreeNode) => void;
/** When true, renders as a mobile overlay drawer instead of a static sidebar. */
mobile?: boolean;
/** Close the mobile drawer. */
onClose?: () => void;
/** Active workspace profile name (null = default). */
activeProfile?: string | null;
/** Fixed width in px when not mobile (overrides default 260). */
width?: number;
/** Called after the user switches to a different profile. */
onProfileSwitch?: () => void;
/** Whether hidden (dot) files/folders are currently shown. */
showHidden?: boolean;
/** Toggle hidden files visibility. */
onToggleHidden?: () => void;
/** Called when the user clicks the collapse/hide sidebar button. */
onCollapse?: () => void;
};
function HomeIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
function FolderOpenIcon() {
return (
<img
src="/icons/folder-open.png"
alt=""
width={20}
height={20}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
/* ─── Theme toggle ─── */
function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
};
return (
<button
type="button"
onClick={toggle}
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
/* Sun icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
/* Moon icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
</button>
);
}
function SearchIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
function SmallFolderIcon() {
return (
<img
src="/icons/folder.png"
alt=""
width={14}
height={14}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
function SmallFileIcon() {
return (
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
function SmallDocIcon() {
return (
<img src="/icons/document.png" alt="" width={14} height={14} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
function SmallDbIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function SuggestTypeIcon({ type }: { type: string }) {
switch (type) {
case "folder": return <SmallFolderIcon />;
case "document": return <SmallDocIcon />;
case "database": return <SmallDbIcon />;
default: return <SmallFileIcon />;
}
}
/* ─── File search ─── */
function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SuggestItem[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced fetch from the same suggest-files API that tiptap uses
useEffect(() => {
if (!query.trim()) {
setResults([]);
setOpen(false);
return;
}
setLoading(true);
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/workspace/suggest-files?q=${encodeURIComponent(query.trim())}`,
);
const data = await res.json();
setResults(data.items ?? []);
setOpen(true);
setSelectedIndex(0);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 150);
return () => clearTimeout(timer);
}, [query]);
// Click outside to close
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && results[selectedIndex]) {
e.preventDefault();
onSelect(results[selectedIndex]);
setQuery("");
setOpen(false);
inputRef.current?.blur();
} else if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
},
[results, selectedIndex, onSelect],
);
const handleSelect = useCallback(
(item: SuggestItem) => {
onSelect(item);
setQuery("");
setOpen(false);
},
[onSelect],
);
return (
<div ref={containerRef} className="relative px-3 pt-2 pb-1">
<div className="relative">
<span
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--color-text-muted)" }}
>
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) {setOpen(true);} }}
placeholder="Search files..."
className="w-full pl-8 pr-3 py-1.5 rounded-lg text-xs outline-none transition-colors"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
/>
{loading && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<UnicodeSpinner
name="braille"
className="text-sm"
style={{ color: "var(--color-text-muted)" }}
/>
</span>
)}
</div>
{open && results.length > 0 && (
<div
className="absolute left-3 right-3 mt-1 rounded-lg shadow-lg border overflow-hidden z-50 max-h-[300px] overflow-y-auto"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{results.map((item, i) => (
<button
key={item.path}
type="button"
onClick={() => handleSelect(item)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-xs cursor-pointer transition-colors"
style={{
background: i === selectedIndex ? "var(--color-surface-hover)" : "transparent",
color: "var(--color-text)",
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="flex-shrink-0" style={{ color: "var(--color-text-muted)" }}>
<SuggestTypeIcon type={item.type} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{item.name}</div>
<div className="truncate" style={{ color: "var(--color-text-muted)", fontSize: "10px" }}>
{item.path.split("/").slice(0, -1).join("/")}
</div>
</div>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 capitalize"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text-muted)" }}
>
{item.type}
</span>
</button>
))}
</div>
)}
{open && query.trim() && !loading && results.length === 0 && (
<div
className="absolute left-3 right-3 mt-1 rounded-lg shadow-lg border z-50 px-3 py-3 text-center"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
No files found
</p>
</div>
)}
</div>
);
}
/** Extract the directory name from an absolute path for display. */
function dirDisplayName(dir: string): string {
if (dir === "/") {return "/";}
return dir.split("/").pop() || dir;
}
export function WorkspaceSidebar({
tree,
activePath,
onSelect,
onRefresh,
orgName,
loading,
browseDir,
parentDir,
onNavigateUp,
onGoHome,
onFileSearchSelect,
workspaceRoot,
onGoToChat,
onExternalDrop,
mobile,
onClose,
activeProfile,
onProfileSwitch,
showHidden,
onToggleHidden,
width: widthProp,
onCollapse,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
const width = mobile ? "280px" : (widthProp ?? 260);
const sidebar = (
<aside
className={`flex flex-col h-screen shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
background: "var(--color-sidebar-bg)",
borderColor: "var(--color-border)",
}}
>
{/* Header */}
<div
className="flex items-center gap-2 px-3 py-2.5 border-b"
style={{ borderColor: "var(--color-border)" }}
>
{isBrowsing ? (
<>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
<FolderOpenIcon />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
title={browseDir}
>
{dirDisplayName(browseDir)}
</div>
<div
className="text-[11px] truncate"
style={{
color: "var(--color-text-muted)",
}}
title={browseDir}
>
{browseDir}
</div>
</div>
{/* Home button to return to workspace */}
{onGoHome && (
<button
type="button"
onClick={onGoHome}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Return to workspace"
>
<HomeIcon />
</button>
)}
</>
) : (
<>
<button
type="button"
onClick={() => void onGoToChat?.()}
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
style={{
background: "transparent",
color: "var(--color-text-muted)",
}}
title="All Chats"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</button>
<ProfileSwitcher
onProfileSwitch={onProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
activeProfileHint={activeProfile}
trigger={({ isOpen, onClick, activeProfile: profileName, switching }) => (
<button
type="button"
onClick={onClick}
disabled={switching}
className="flex-1 min-w-0 w-full flex items-center justify-between gap-1.5 text-left rounded-lg py-1 px-1.5 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800 disabled:opacity-50"
title="Switch workspace profile"
>
<div className="min-w-0 truncate">
<div
className="text-[13px] font-semibold truncate text-stone-700 dark:text-stone-200"
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px] flex items-center gap-1 truncate text-stone-400 dark:text-stone-500"
>
<span>Ironclaw</span>
{profileName && profileName !== "default" && (
<span
className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-500 dark:bg-stone-700 dark:text-stone-400"
>
{profileName}
</span>
)}
</div>
</div>
<svg
className={`w-3 h-3 shrink-0 transition-transform text-stone-400 ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
/>
</>
)}
{onCollapse && (
<button
type="button"
onClick={onCollapse}
className="p-1 rounded-md shrink-0 transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
style={{ color: "var(--color-text-muted)" }}
title="Hide sidebar (⌘B)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
)}
</div>
{/* Create workspace dialog */}
<CreateWorkspaceDialog
isOpen={showCreateWorkspace}
onClose={() => setShowCreateWorkspace(false)}
onCreated={onProfileSwitch}
/>
{/* File search */}
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />
)}
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<UnicodeSpinner
name="braille"
className="text-2xl"
style={{ color: "var(--color-text-muted)" }}
/>
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
workspaceRoot={workspaceRoot}
onExternalDrop={onExternalDrop}
/>
)}
</div>
{/* Footer */}
<div
className="px-3 py-2.5 border-t flex items-center justify-between"
style={{ borderColor: "var(--color-border)" }}
>
<a
href="https://ironclaw.sh"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm"
style={{ color: "var(--color-text-muted)" }}
>
ironclaw.sh
</a>
<div className="flex items-center gap-0.5">
{onToggleHidden && (
<button
type="button"
onClick={onToggleHidden}
className="p-1.5 rounded-lg transition-colors"
style={{ color: showHidden ? "var(--color-accent)" : "var(--color-text-muted)" }}
title={showHidden ? "Hide dotfiles" : "Show dotfiles"}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{showHidden ? (
<>
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</>
) : (
<>
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</>
)}
</svg>
</button>
)}
<ThemeToggle />
</div>
</div>
</aside>
);
if (!mobile) { return sidebar; }
return (
<div className="drawer-backdrop" onClick={() => void onClose?.()}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()} className="fixed inset-y-0 left-0 z-50">
{sidebar}
</div>
</div>
);
}