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
|
<WorkspaceView
|
||||||
tree={tree}
|
tree={tree}
|
||||||
initialPath={workspaceInitialPath}
|
initialPath={workspaceInitialPath}
|
||||||
|
actions={{
|
||||||
|
remove: knowledgeActions.remove,
|
||||||
|
copyPath: knowledgeActions.copyPath,
|
||||||
|
revealInFileManager: knowledgeActions.revealInFileManager,
|
||||||
|
}}
|
||||||
onOpenNote={(path) => navigateToFile(path)}
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
|
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 * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -25,7 +26,7 @@ import { toast } from "sonner"
|
||||||
import { AccountSettings } from "@/components/settings/account-settings"
|
import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-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 {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -43,10 +44,10 @@ const tabs: TabConfig[] = [
|
||||||
description: "Manage your Rowboat account",
|
description: "Manage your Rowboat account",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "connected-accounts",
|
id: "connections",
|
||||||
label: "Connected Accounts",
|
label: "Connections",
|
||||||
icon: Plug,
|
icon: Plug,
|
||||||
description: "Manage connected services",
|
description: "Manage accounts and tools",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "models",
|
id: "models",
|
||||||
|
|
@ -75,12 +76,6 @@ const tabs: TabConfig[] = [
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
description: "Customize the look and feel",
|
description: "Customize the look and feel",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "tools",
|
|
||||||
label: "Tools Library",
|
|
||||||
icon: Wrench,
|
|
||||||
description: "Browse and enable toolkits",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "note-tagging",
|
id: "note-tagging",
|
||||||
label: "Note Tagging",
|
label: "Note Tagging",
|
||||||
|
|
@ -88,10 +83,80 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/tags.json",
|
path: "config/tags.json",
|
||||||
description: "Configure tags for notes and emails",
|
description: "Configure tags for notes and emails",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "help",
|
||||||
|
label: "Help",
|
||||||
|
icon: HelpCircle,
|
||||||
|
description: "Get help and support",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
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 ---
|
// --- Theme option for Appearance tab ---
|
||||||
|
|
@ -1572,9 +1637,14 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTab>("account")
|
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 [content, setContent] = useState("")
|
||||||
const [originalContent, setOriginalContent] = useState("")
|
const [originalContent, setOriginalContent] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -1582,6 +1652,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [rowboatConnected, setRowboatConnected] = useState(false)
|
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
|
// Check if user is signed in to Rowboat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -1607,7 +1682,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
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)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -1673,7 +1748,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
{children && <DialogTrigger asChild>{children}</DialogTrigger>}
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
|
|
@ -1715,11 +1790,21 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connected-accounts" ? (
|
) : activeTab === "connections" ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold">Primary accounts</h4>
|
||||||
<ConnectedAccountsSettings dialogOpen={open} />
|
<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" ? (
|
) : activeTab === "models" ? (
|
||||||
rowboatConnected
|
rowboatConnected
|
||||||
? <RowboatModelSettings dialogOpen={open} />
|
? <RowboatModelSettings dialogOpen={open} />
|
||||||
|
|
@ -1728,8 +1813,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||||
<NoteTaggingSettings dialogOpen={open} />
|
<NoteTaggingSettings dialogOpen={open} />
|
||||||
) : activeTab === "appearance" ? (
|
) : activeTab === "appearance" ? (
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : activeTab === "tools" ? (
|
) : activeTab === "help" ? (
|
||||||
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
|
<HelpSettings />
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider}
|
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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -119,15 +119,15 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
{/* Email & Calendar Section */}
|
{/* Email & Calendar Section */}
|
||||||
{(c.useComposioForGoogle || c.useComposioForGoogleCalendar || c.providers.includes('google')) && (
|
{(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">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Email & Calendar
|
Email & Calendar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{c.useComposioForGoogle ? (
|
{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 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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<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.providers.includes('google') && renderOAuthProvider('google', 'Google', <Mail className="size-4" />, 'Sync emails and calendar')
|
||||||
)}
|
)}
|
||||||
{c.useComposioForGoogleCalendar && (
|
{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 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 items-center gap-2.5 min-w-0">
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
<Calendar className="size-4" />
|
<Calendar className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
|
|
@ -220,14 +220,14 @@ export function ConnectedAccountsSettings({ dialogOpen }: ConnectedAccountsSetti
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator className="my-3" />
|
<Separator className="my-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Meeting Notes Section */}
|
{/* Meeting Notes Section */}
|
||||||
{c.providers.includes('fireflies-ai') && (
|
{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">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Meeting Notes
|
Meeting Notes
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Globe,
|
Globe,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
HelpCircle,
|
|
||||||
Home,
|
Home,
|
||||||
Mic,
|
Mic,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
|
|
@ -79,8 +78,6 @@ import {
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ConnectorsPopover } from "@/components/connectors-popover"
|
|
||||||
import { HelpPopover } from "@/components/help-popover"
|
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { toast } from "@/lib/toast"
|
import { toast } from "@/lib/toast"
|
||||||
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
|
import { formatRelativeTime as formatRunTime } from "@/lib/relative-time"
|
||||||
|
|
@ -456,8 +453,8 @@ export function SidebarContentPanel({
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const [hasOauthError, setHasOauthError] = useState(false)
|
const [hasOauthError, setHasOauthError] = useState(false)
|
||||||
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
const [showOauthAlert, setShowOauthAlert] = useState(true)
|
||||||
const [connectorsOpen, setConnectorsOpen] = useState(false)
|
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
|
||||||
const [openConnectorsAfterClose, setOpenConnectorsAfterClose] = useState(false)
|
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
|
||||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
const [loggingIn, setLoggingIn] = useState(false)
|
const [loggingIn, setLoggingIn] = useState(false)
|
||||||
|
|
@ -575,11 +572,11 @@ export function SidebarContentPanel({
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<EmailSidebarSection
|
<EmailSidebarSection
|
||||||
onOpenEmailView={onOpenEmail}
|
onOpenEmailView={onOpenEmail}
|
||||||
onOpenConnectors={() => setConnectorsOpen(true)}
|
onOpenConnectors={() => setConnectionsSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
<MeetingsSidebarSection
|
<MeetingsSidebarSection
|
||||||
onOpenMeetingsView={onOpenMeetings}
|
onOpenMeetingsView={onOpenMeetings}
|
||||||
onOpenConnectors={() => setConnectorsOpen(true)}
|
onOpenConnectors={() => setConnectionsSettingsOpen(true)}
|
||||||
recordingState={meetingRecordingState ?? 'idle'}
|
recordingState={meetingRecordingState ?? 'idle'}
|
||||||
recordingSource={recordingMeetingSource ?? null}
|
recordingSource={recordingMeetingSource ?? null}
|
||||||
onToggleRecording={onToggleMeetingRecording}
|
onToggleRecording={onToggleMeetingRecording}
|
||||||
|
|
@ -645,15 +642,14 @@ export function SidebarContentPanel({
|
||||||
<div className="border-t border-sidebar-border px-2 py-2">
|
<div className="border-t border-sidebar-border px-2 py-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ConnectorsPopover open={connectorsOpen} onOpenChange={setConnectorsOpen} mode="unconnected">
|
|
||||||
<button
|
<button
|
||||||
ref={connectorsButtonRef}
|
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"
|
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" />
|
<Plug className="size-4" />
|
||||||
<span>Connect Accounts</span>
|
<span>Connect Accounts</span>
|
||||||
</button>
|
</button>
|
||||||
</ConnectorsPopover>
|
|
||||||
{hasOauthError && (
|
{hasOauthError && (
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={showOauthAlert}
|
open={showOauthAlert}
|
||||||
|
|
@ -671,9 +667,9 @@ export function SidebarContentPanel({
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (openConnectorsAfterClose) {
|
if (openConnectionsAfterClose) {
|
||||||
setOpenConnectorsAfterClose(false)
|
setOpenConnectionsAfterClose(false)
|
||||||
setConnectorsOpen(true)
|
setConnectionsSettingsOpen(true)
|
||||||
}
|
}
|
||||||
connectorsButtonRef.current?.focus()
|
connectorsButtonRef.current?.focus()
|
||||||
}}
|
}}
|
||||||
|
|
@ -696,7 +692,7 @@ export function SidebarContentPanel({
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenConnectorsAfterClose(true)
|
setOpenConnectionsAfterClose(true)
|
||||||
setShowOauthAlert(false)
|
setShowOauthAlert(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -713,14 +709,13 @@ export function SidebarContentPanel({
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</SettingsDialog>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsDialog
|
||||||
|
defaultTab="connections"
|
||||||
|
open={connectionsSettingsOpen}
|
||||||
|
onOpenChange={setConnectionsSettingsOpen}
|
||||||
|
/>
|
||||||
<SyncStatusBar />
|
<SyncStatusBar />
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|
@ -1005,7 +1000,7 @@ function KnowledgeSection({
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarGroup className="flex flex-col">
|
<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
|
Notes
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|
@ -1078,7 +1073,7 @@ export function WorkspaceSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex flex-col">
|
<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
|
Workspace
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|
@ -1319,7 +1314,7 @@ function EmailSidebarSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex flex-col">
|
<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
|
Email
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|
@ -1469,7 +1464,7 @@ function MeetingsSidebarSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex flex-col">
|
<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
|
Meetings
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|
@ -1608,7 +1603,7 @@ function TasksSidebarSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex flex-col">
|
<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
|
Tasks
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
|
|
@ -1666,7 +1661,7 @@ function TasksSection({
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className="flex flex-col">
|
<SidebarGroup className="flex flex-col">
|
||||||
<SidebarGroupContent>
|
<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
|
Chat history
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
|
|
||||||
|
|
@ -380,7 +380,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
data-slot="sidebar-content"
|
data-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -393,7 +393,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot="sidebar-group"
|
||||||
data-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -462,7 +462,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot="sidebar-menu"
|
||||||
data-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,33 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ChevronRight, File as FileIcon, Folder as FolderIcon, Home, Plus } from 'lucide-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 { 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,6 +37,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||||
|
|
@ -22,13 +49,28 @@ interface TreeNode {
|
||||||
children?: TreeNode[]
|
children?: TreeNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkspaceActions = {
|
||||||
|
remove: (path: string) => Promise<void>
|
||||||
|
copyPath: (path: string) => void
|
||||||
|
revealInFileManager: (path: string, isDir: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceViewProps = {
|
type WorkspaceViewProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
initialPath?: string | null
|
initialPath?: string | null
|
||||||
|
actions: WorkspaceActions
|
||||||
onOpenNote: (path: string) => void
|
onOpenNote: (path: string) => void
|
||||||
onCreateWorkspace: (name: string) => Promise<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 {
|
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
|
||||||
if (!nodes) return null
|
if (!nodes) return null
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|
@ -46,18 +88,51 @@ function countChildren(node: TreeNode | null): number {
|
||||||
return node.children.length
|
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 [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (initialPath) setCurrentPath(initialPath)
|
if (initialPath) setCurrentPath(initialPath)
|
||||||
}, [initialPath])
|
}, [initialPath])
|
||||||
|
|
||||||
const isRoot = currentPath === WORKSPACE_ROOT
|
const isRoot = currentPath === WORKSPACE_ROOT
|
||||||
|
const fileManagerName = getFileManagerName()
|
||||||
|
|
||||||
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
|
const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath])
|
||||||
|
|
||||||
|
|
@ -83,15 +158,109 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
|
||||||
|
|
||||||
const handleItemClick = useCallback(
|
const handleItemClick = useCallback(
|
||||||
(item: TreeNode) => {
|
(item: TreeNode) => {
|
||||||
|
if (renameTarget) return
|
||||||
if (item.kind === 'dir') {
|
if (item.kind === 'dir') {
|
||||||
setCurrentPath(item.path)
|
setCurrentPath(item.path)
|
||||||
} else {
|
} else {
|
||||||
onOpenNote(item.path)
|
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(() => {
|
const resetAddDialog = useCallback(() => {
|
||||||
setNewName('')
|
setNewName('')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -157,22 +326,70 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{isRoot && (
|
{isRoot ? (
|
||||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add workspace
|
Add workspace
|
||||||
</Button>
|
</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>
|
</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 ? (
|
{items.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
|
||||||
<FolderIcon className="size-10 opacity-50" />
|
<FolderIcon className="size-10 opacity-50" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{isRoot
|
{isRoot
|
||||||
? 'No workspaces yet. Create one to get started.'
|
? '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>
|
</div>
|
||||||
{isRoot && (
|
{isRoot && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||||
|
|
@ -186,17 +403,33 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const childCount = item.kind === 'dir' ? countChildren(item) : 0
|
const childCount = item.kind === 'dir' ? countChildren(item) : 0
|
||||||
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
|
const Icon = item.kind === 'dir' ? FolderIcon : FileIcon
|
||||||
return (
|
const isRenaming = renameTarget === item.path
|
||||||
|
const card = (
|
||||||
<button
|
<button
|
||||||
key={item.path}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleItemClick(item)}
|
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" />
|
<Icon className="size-6 text-muted-foreground group-hover:text-foreground" />
|
||||||
<div className="min-w-0 w-full">
|
<div className="min-w-0 w-full">
|
||||||
|
{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>
|
<div className="truncate text-sm font-medium">{item.name}</div>
|
||||||
{item.kind === 'dir' && (
|
)}
|
||||||
|
{item.kind === 'dir' && !isRenaming && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{childCount} {childCount === 1 ? 'item' : 'items'}
|
{childCount} {childCount === 1 ? 'item' : 'items'}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,9 +437,45 @@ export function WorkspaceView({ tree, initialPath, onOpenNote, onCreateWorkspace
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue