mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
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:
parent
193c2a9131
commit
e6587a67b7
7 changed files with 435 additions and 181 deletions
|
|
@ -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) }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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...
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue