settings connections tab, denser sidebar, and help in settings

Merge Connected Accounts and Tools Library into a single Connections tab,
tighten sidebar spacing, move Help into Settings, and refine the Workspace
view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arjun 2026-05-22 15:47:03 +05:30 committed by arkml
parent 193c2a9131
commit e6587a67b7
7 changed files with 435 additions and 181 deletions

View file

@ -5314,6 +5314,11 @@ function App() {
<WorkspaceView
tree={tree}
initialPath={workspaceInitialPath}
actions={{
remove: knowledgeActions.remove,
copyPath: knowledgeActions.copyPath,
revealInFileManager: knowledgeActions.revealInFileManager,
}}
onOpenNote={(path) => navigateToFile(path)}
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
/>

View file

@ -1,100 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { MessageCircle } from "lucide-react"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
interface HelpPopoverProps {
children: React.ReactNode
tooltip?: string
}
export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
const [open, setOpen] = useState(false)
const handleDiscordClick = () => {
window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")
}
return (
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
<Tooltip open={open ? false : undefined}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
)}
<PopoverContent
side="right"
align="end"
sideOffset={4}
className="w-80 p-0"
>
<div className="p-4 border-b">
<h4 className="font-semibold text-sm">Help & Support</h4>
<p className="text-xs text-muted-foreground mt-1">
Get help from our community
</p>
</div>
<div className="p-2">
<Button
variant="ghost"
className="w-full justify-start gap-3 h-auto py-3"
onClick={handleDiscordClick}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">
Chat with the community
</span>
</div>
</Button>
</div>
<div className="px-4 py-3 border-t flex justify-center gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react"
import {
Dialog,
@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
@ -25,7 +26,7 @@ import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -43,10 +44,10 @@ const tabs: TabConfig[] = [
description: "Manage your Rowboat account",
},
{
id: "connected-accounts",
label: "Connected Accounts",
id: "connections",
label: "Connections",
icon: Plug,
description: "Manage connected services",
description: "Manage accounts and tools",
},
{
id: "models",
@ -75,12 +76,6 @@ const tabs: TabConfig[] = [
icon: Palette,
description: "Customize the look and feel",
},
{
id: "tools",
label: "Tools Library",
icon: Wrench,
description: "Browse and enable toolkits",
},
{
id: "note-tagging",
label: "Note Tagging",
@ -88,10 +83,80 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "help",
label: "Help",
icon: HelpCircle,
description: "Get help and support",
},
]
interface SettingsDialogProps {
children: React.ReactNode
/** Optional trigger element. Omit when controlling `open` externally. */
children?: React.ReactNode
/** Tab to open on when the dialog is shown. Defaults to "account". */
defaultTab?: ConfigTab
/** Controlled open state. When provided, the dialog is fully controlled. */
open?: boolean
onOpenChange?: (open: boolean) => void
}
// --- Help & Support tab ---
function HelpSettings() {
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#5865F2]">
<MessageCircle className="size-4 text-white" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Join our Discord</span>
<span className="text-xs text-muted-foreground">Chat with the community</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("mailto:contact@rowboatlabs.com", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Contact us</span>
<span className="text-xs text-muted-foreground">contact@rowboatlabs.com</span>
</div>
</Button>
<div className="flex gap-3 text-xs text-muted-foreground">
<a
href="https://www.rowboatlabs.com/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Terms of Service
</a>
<span>·</span>
<a
href="https://www.rowboatlabs.com/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
Privacy Policy
</a>
</div>
</div>
)
}
// --- Theme option for Appearance tab ---
@ -1572,9 +1637,14 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = controlledOpen ?? internalOpen
const setOpen = useCallback((next: boolean) => {
if (onOpenChange) onOpenChange(next)
else setInternalOpen(next)
}, [onOpenChange])
const [activeTab, setActiveTab] = useState<ConfigTab>(defaultTab)
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
@ -1582,6 +1652,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
const [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false)
// Reset to the requested default tab each time the dialog is opened
useEffect(() => {
if (open) setActiveTab(defaultTab)
}, [open, defaultTab])
// Check if user is signed in to Rowboat
useEffect(() => {
if (!open) return
@ -1607,7 +1682,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connected-accounts") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -1673,7 +1748,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
<DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
@ -1715,11 +1790,21 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
<ConnectedAccountsSettings dialogOpen={open} />
) : activeTab === "connections" ? (
<div className="space-y-6">
<div className="space-y-2">
<h4 className="text-sm font-semibold">Primary accounts</h4>
<ConnectedAccountsSettings dialogOpen={open} />
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Library</h4>
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
</div>
</div>
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
@ -1728,8 +1813,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
) : activeTab === "help" ? (
<HelpSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
return (
<div
key={provider}
className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
{icon}
</div>
<div className="flex flex-col min-w-0">
@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
{/* Email & Calendar Section */}
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
<>
<div className="px-4 py-2">
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Email & Calendar
</span>
</div>
{c.useComposioForGoogle ? (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Mail className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -174,9 +174,9 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
c.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
)}
{c.useComposioForGoogleCalendar && (
<div className="flex items-center justify-between gap-3 rounded-lg px-4 py-3 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<div className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Calendar className="size-4" />
</div>
<div className="flex flex-col min-w-0">
@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
</div>
</div>
)}
<Separator className="my-3" />
<Separator className="my-2" />
</>
)}
{/* Meeting Notes Section */}
{c.providers.includes('fireflies-ai') && (
<>
<div className="px-4 py-2">
<div className="px-3 pt-1 pb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Meeting Notes
</span>

View file

@ -13,7 +13,6 @@ import {
FolderPlus,
Globe,
AlertTriangle,
HelpCircle,
Home,
Mic,
SearchIcon,
@ -79,8 +78,6 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { cn } from "@/lib/utils"
import { ConnectorsPopover } from "@/components/connectors-popover"
import { HelpPopover } from "@/components/help-popover"
import { SettingsDialog } from "@/components/settings-dialog"
import { toast } from "@/lib/toast"
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
@ -456,8 +453,8 @@ export function SidebarContentPanel({
}: SidebarContentPanelProps) {
const [hasOauthError, setHasOauthError] = useState(false)
const [showOauthAlert, setShowOauthAlert] = useState(true)
const [connectorsOpen, setConnectorsOpen] = useState(false)
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
const [loggingIn, setLoggingIn] = useState(false)
@ -575,11 +572,11 @@ export function SidebarContentPanel({
<SidebarContent>
<EmailSidebarSection
onOpenEmailView={onOpenEmail}
onOpenConnectors={() => setConnectorsOpen(true)}
onOpenConnectors={() => setConnectionsSettingsOpen(true)}
/>
<MeetingsSidebarSection
onOpenMeetingsView={onOpenMeetings}
onOpenConnectors={() => setConnectorsOpen(true)}
onOpenConnectors={() => setConnectionsSettingsOpen(true)}
recordingState={meetingRecordingState ?? 'idle'}
recordingSource={recordingMeetingSource ?? null}
onToggleRecording={onToggleMeetingRecording}
@ -645,15 +642,14 @@ export function SidebarContentPanel({
<div className="border-t border-sidebar-border px-2 py-2">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen} mode="unconnected">
<button
ref={connectorsButtonRef}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-4" />
<span>Connect Accounts</span>
</button>
</ConnectorsPopover>
<button
ref={connectorsButtonRef}
onClick={() => setConnectionsSettingsOpen(true)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<Plug className="size-4" />
<span>Connect Accounts</span>
</button>
{hasOauthError && (
<AlertDialog
open={showOauthAlert}
@ -671,9 +667,9 @@ export function SidebarContentPanel({
<AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault()
if (openConnectorsAfterClose) {
setOpenConnectorsAfterClose(false)
setConnectorsOpen(true)
if (openConnectionsAfterClose) {
setOpenConnectionsAfterClose(false)
setConnectionsSettingsOpen(true)
}
connectorsButtonRef.current?.focus()
}}
@ -696,7 +692,7 @@ export function SidebarContentPanel({
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setOpenConnectorsAfterClose(true)
setOpenConnectionsAfterClose(true)
setShowOauthAlert(false)
}}
>
@ -713,14 +709,13 @@ export function SidebarContentPanel({
<span>Settings</span>
</button>
</SettingsDialog>
<HelpPopover>
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
<HelpCircle className="size-4" />
<span>Help</span>
</button>
</HelpPopover>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
<SyncStatusBar />
<SidebarRail />
</Sidebar>
@ -1005,7 +1000,7 @@ function KnowledgeSection({
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Notes
</div>
<SidebarGroupContent>
@ -1078,7 +1073,7 @@ export function WorkspaceSection({
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Workspace
</div>
<SidebarGroupContent>
@ -1319,7 +1314,7 @@ function EmailSidebarSection({
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Email
</div>
<SidebarGroupContent>
@ -1469,7 +1464,7 @@ function MeetingsSidebarSection({
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Meetings
</div>
<SidebarGroupContent>
@ -1608,7 +1603,7 @@ function TasksSidebarSection({
return (
<SidebarGroup className="flex flex-col">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Tasks
</div>
<SidebarGroupContent>
@ -1666,7 +1661,7 @@ function TasksSection({
return (
<SidebarGroup className="flex flex-col">
<SidebarGroupContent>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
<div className="px-3 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Chat history
</div>
<SidebarMenu>

View file

@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-1 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
{...props}
/>
)
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)

View file

@ -1,7 +1,33 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronRight, File as FileIcon, Folder as FolderIcon, Home, Plus } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronRight,
Copy,
File as FileIcon,
FilePlus,
Folder as FolderIcon,
FolderOpen,
FolderPlus,
Home,
Pencil,
Plus,
Trash2,
UploadCloud,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
@ -11,6 +37,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
const WORKSPACE_ROOT = 'knowledge/Workspace'
@ -22,13 +49,28 @@ interface TreeNode {
children?: TreeNode[]
}
type WorkspaceActions = {
remove: (path: string) => Promise<void>
copyPath: (path: string) => void
revealInFileManager: (path: string, isDir: boolean) => void
}
type WorkspaceViewProps = {
tree: TreeNode[]
initialPath?: string | null
actions: WorkspaceActions
onOpenNote: (path: string) => void
onCreateWorkspace: (name: string) => Promise<void>
}
function getFileManagerName(): string {
if (typeof navigator === 'undefined') return 'File Manager'
const platform = navigator.platform.toLowerCase()
if (platform.includes('mac')) return 'Finder'
if (platform.includes('win')) return 'Explorer'
return 'File Manager'
}
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
if (!nodes) return null
for (const node of nodes) {
@ -46,18 +88,51 @@ function countChildren(node: TreeNode | null): number {
return node.children.length
}
export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
async function uniqueChildPath(parent: string, name: string): Promise<string> {
const dot = name.lastIndexOf('.')
const base = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
let candidate = `${parent}/${name}`
let i = 1
while ((await window.ipc.invoke('workspace:exists', { path: candidate })).exists) {
candidate = `${parent}/${base} (${i})${ext}`
i += 1
}
return candidate
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
resolve(result.split(',')[1] ?? '')
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [uploading, setUploading] = useState(false)
const dragDepthRef = useRef(0)
const filesInputRef = useRef<HTMLInputElement | null>(null)
const folderInputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (initialPath) setCurrentPath(initialPath)
}, [initialPath])
const isRoot = currentPath === WORKSPACE_ROOT
const fileManagerName = getFileManagerName()
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
@ -83,15 +158,109 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
const handleItemClick = useCallback(
(item: TreeNode) => {
if (renameTarget) return
if (item.kind === 'dir') {
setCurrentPath(item.path)
} else {
onOpenNote(item.path)
}
},
[onOpenNote],
[onOpenNote, renameTarget],
)
const beginRename = useCallback((item: TreeNode) => {
setRenameTarget(item.path)
setRenameValue(item.name)
}, [])
const commitRename = useCallback(async () => {
if (!renameTarget) return
const node = items.find((i) => i.path === renameTarget)
const trimmed = renameValue.trim()
setRenameTarget(null)
if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return
const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/'))
try {
await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` })
toast('Renamed', 'success')
} catch {
toast('Failed to rename', 'error')
}
}, [renameTarget, renameValue, items])
const handleDelete = useCallback(async (item: TreeNode) => {
try {
await actions.remove(item.path)
toast('Moved to trash', 'success')
} catch {
toast('Failed to delete', 'error')
}
}, [actions])
const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => {
const list = Array.from(files)
if (list.length === 0) return
setUploading(true)
try {
for (const file of list) {
const data = await readFileAsBase64(file)
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath
const target = preserveStructure && rel
? `${currentPath}/${rel}`
: await uniqueChildPath(currentPath, file.name)
await window.ipc.invoke('workspace:writeFile', {
path: target,
data,
opts: { encoding: 'base64', mkdirp: true },
})
}
toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success')
} catch (err) {
console.error('Failed to add files:', err)
toast('Failed to add', 'error')
} finally {
setUploading(false)
}
}, [currentPath])
// Drag-and-drop (only inside a workspace folder, not at the root grid).
// stopPropagation keeps the drop from also reaching the copilot's
// document-level drop listener when it lands on the workspace area.
const dropEnabled = !isRoot
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current += 1
setIsDraggingOver(true)
}, [dropEnabled])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
if (!Array.from(e.dataTransfer.types).includes('Files')) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [dropEnabled])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current -= 1
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0
setIsDraggingOver(false)
}
}, [dropEnabled])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!dropEnabled) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current = 0
setIsDraggingOver(false)
if (e.dataTransfer.files?.length) void uploadFiles(e.dataTransfer.files)
}, [dropEnabled, uploadFiles])
const resetAddDialog = useCallback(() => {
setNewName('')
setError(null)
@ -157,22 +326,70 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
)
})}
</div>
{isRoot && (
{isRoot ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">
<Plus className="size-4" />
Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
<FilePlus className="mr-2 size-4" />
Add files
</DropdownMenuItem>
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderPlus className="mr-2 size-4" />
Add folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<input
ref={filesInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, false)
e.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
// @ts-expect-error non-standard but supported in Chromium/Electron
webkitdirectory=""
directory=""
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) void uploadFiles(e.target.files, true)
e.target.value = ''
}}
/>
<div className="flex-1 overflow-y-auto px-6 py-6">
<div
className="relative flex-1 overflow-y-auto px-6 py-6"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
<FolderIcon className="size-10 opacity-50" />
<div className="text-sm">
{isRoot
? 'No workspaces yet. Create one to get started.'
: 'This folder is empty.'}
: 'This folder is empty. Drag files in or use New note / New folder.'}
</div>
{isRoot && (
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
@ -186,17 +403,33 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
{items.map((item) => {
const childCount = item.kind === 'dir' ? countChildren(item) : 0
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
return (
const isRenaming = renameTarget === item.path
const card = (
<button
key={item.path}
type="button"
onClick={() => handleItemClick(item)}
className="group flex flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
className="group flex w-full flex-col items-start gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-foreground/20 hover:bg-accent"
>
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
<div className="min-w-0 w-full">
<div className="truncate text-sm font-medium">{item.name}</div>
{item.kind === 'dir' && (
{isRenaming ? (
<Input
autoFocus
value={renameValue}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); void commitRename() }
else if (e.key === 'Escape') { e.preventDefault(); setRenameTarget(null) }
}}
className="h-6 text-sm"
/>
) : (
<div className="truncate text-sm font-medium">{item.name}</div>
)}
{item.kind === 'dir' && !isRenaming && (
<div className="text-xs text-muted-foreground">
{childCount} {childCount === 1 ? 'item' : 'items'}
</div>
@ -204,9 +437,45 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
</div>
</button>
)
return (
<ContextMenu key={item.path}>
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => beginRename(item)}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
<Copy className="mr-2 size-4" />
Copy Path
</ContextMenuItem>
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, item.kind === 'dir')}>
<FolderOpen className="mr-2 size-4" />
Show in {fileManagerName}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
<Trash2 className="mr-2 size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
)}
{dropEnabled && isDraggingOver && (
<div className="pointer-events-none absolute inset-3 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5 text-primary">
<UploadCloud className="size-8" />
<span className="text-sm font-medium">Drop files to add to this folder</span>
</div>
)}
{uploading && (
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-md bg-foreground/80 px-3 py-1.5 text-xs text-background">
Adding files
</div>
)}
</div>
<Dialog