mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 03:42:38 +02:00
added right click content menu to knowledge
This commit is contained in:
parent
7ab811f27f
commit
8834238768
8 changed files with 742 additions and 147 deletions
|
|
@ -11,10 +11,12 @@
|
|||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
|
|
|||
|
|
@ -222,7 +222,6 @@ const chatHistory = [
|
|||
|
||||
function App() {
|
||||
// File browser state (for Knowledge section)
|
||||
const [_knowledgeContent, setKnowledgeContent] = useState<string>('')
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string>('')
|
||||
const [tree, setTree] = useState<TreeNode[]>([])
|
||||
|
|
@ -238,65 +237,40 @@ function App() {
|
|||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [agentId] = useState<string>('copilot')
|
||||
|
||||
// Load directory and merge into tree
|
||||
const loadDirectory = useCallback(async (path: string = '') => {
|
||||
// Load directory tree
|
||||
const loadDirectory = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readdir', {
|
||||
path,
|
||||
path: 'knowledge',
|
||||
opts: { recursive: true, includeHidden: false }
|
||||
})
|
||||
const tree = buildTree(result)
|
||||
return tree
|
||||
return buildTree(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to load directory:', err)
|
||||
return []
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load knowledge file content
|
||||
const loadKnowledge = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: 'knowledge',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
return result.data
|
||||
} catch (err) {
|
||||
console.error('Failed to load knowledge file:', err)
|
||||
return ''
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load initial tree and knowledge content
|
||||
// Load initial tree
|
||||
useEffect(() => {
|
||||
async function process() {
|
||||
const [treeData, content] = await Promise.all([
|
||||
loadDirectory(),
|
||||
loadKnowledge()
|
||||
]);
|
||||
setTree(treeData)
|
||||
setKnowledgeContent(content)
|
||||
}
|
||||
process();
|
||||
}, [loadDirectory, loadKnowledge])
|
||||
loadDirectory().then(setTree)
|
||||
}, [loadDirectory])
|
||||
|
||||
// Listen to workspace change events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('workspace:didChange', () => {
|
||||
// Reload tree and knowledge on any change
|
||||
loadDirectory().then(result => setTree(result))
|
||||
loadKnowledge().then(result => setKnowledgeContent(result))
|
||||
loadDirectory().then(setTree)
|
||||
})
|
||||
return cleanup
|
||||
}, [loadDirectory, loadKnowledge])
|
||||
}, [loadDirectory])
|
||||
|
||||
// Load file content when selected
|
||||
useEffect(() => {
|
||||
async function process() {
|
||||
if (!selectedPath) {
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
if (!selectedPath) {
|
||||
setFileContent('')
|
||||
return
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const stat = await window.ipc.invoke('workspace:stat', { path: selectedPath })
|
||||
if (stat.kind === 'file') {
|
||||
|
|
@ -307,9 +281,9 @@ function App() {
|
|||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file:', err)
|
||||
setFileContent('')
|
||||
}
|
||||
}
|
||||
process();
|
||||
})()
|
||||
}, [selectedPath])
|
||||
|
||||
// Listen to run events
|
||||
|
|
@ -520,6 +494,76 @@ function App() {
|
|||
setExpandedPaths(newExpanded)
|
||||
}
|
||||
|
||||
// Knowledge quick actions
|
||||
const collectDirPaths = (nodes: TreeNode[]): string[] =>
|
||||
nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : [])
|
||||
|
||||
// Get workspace root for full paths
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string>('')
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('workspace:getRoot', null).then(result => {
|
||||
setWorkspaceRoot(result.root)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const knowledgeActions = React.useMemo(() => ({
|
||||
createNote: async (parentPath: string = 'knowledge') => {
|
||||
try {
|
||||
const name = `untitled-${Date.now()}.md`
|
||||
const fullPath = `${parentPath}/${name}`
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: fullPath,
|
||||
data: `# New Note\n\n`,
|
||||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
setSelectedPath(fullPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
createFolder: async (parentPath: string = 'knowledge') => {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:mkdir', {
|
||||
path: `${parentPath}/new-folder-${Date.now()}`,
|
||||
recursive: true
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
|
||||
collapseAll: () => setExpandedPaths(new Set()),
|
||||
rename: async (oldPath: string, newName: string, isDir: boolean) => {
|
||||
try {
|
||||
const parts = oldPath.split('/')
|
||||
// For files, ensure .md extension
|
||||
const finalName = isDir ? newName : (newName.endsWith('.md') ? newName : `${newName}.md`)
|
||||
parts[parts.length - 1] = finalName
|
||||
const newPath = parts.join('/')
|
||||
await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath })
|
||||
if (selectedPath === oldPath) setSelectedPath(newPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename:', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
remove: async (path: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } })
|
||||
if (selectedPath === path) setSelectedPath(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove:', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
copyPath: (path: string) => {
|
||||
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
|
||||
navigator.clipboard.writeText(fullPath)
|
||||
},
|
||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
|
||||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
if (isChatMessage(item)) {
|
||||
return (
|
||||
|
|
@ -629,6 +673,7 @@ function App() {
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
knowledgeActions={knowledgeActions}
|
||||
chats={chatHistory}
|
||||
/>
|
||||
<SidebarInset>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
Database,
|
||||
File,
|
||||
FilePlus,
|
||||
|
|
@ -19,8 +20,10 @@ import {
|
|||
MessageSquarePlus,
|
||||
Microscope,
|
||||
Network,
|
||||
Pencil,
|
||||
Plug,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
|
|
@ -49,6 +52,14 @@ import {
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useSidebarSection } from "@/contexts/sidebar-context"
|
||||
import { useOAuth, useAvailableProviders } from "@/hooks/useOAuth"
|
||||
import { toast } from "@/lib/toast"
|
||||
|
|
@ -61,11 +72,22 @@ interface TreeNode {
|
|||
loaded?: boolean
|
||||
}
|
||||
|
||||
type KnowledgeActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||
remove: (path: string) => Promise<void>
|
||||
copyPath: (path: string) => void
|
||||
}
|
||||
|
||||
type SidebarContentPanelProps = {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
knowledgeActions: KnowledgeActions
|
||||
chats: { id: string; title: string; preview: string; time: string }[]
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
|
|
@ -75,12 +97,6 @@ const sectionTitles = {
|
|||
agents: "Agents",
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => console.log("New note") },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => console.log("New folder") },
|
||||
{ icon: Network, label: "Graph View", action: () => console.log("Graph view") },
|
||||
{ icon: ArrowDownAZ, label: "Sort", action: () => console.log("Sort") },
|
||||
]
|
||||
|
||||
const agentPresets = [
|
||||
{
|
||||
|
|
@ -149,15 +165,11 @@ export function SidebarContentPanel({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
knowledgeActions,
|
||||
chats,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
const [allExpanded, setAllExpanded] = React.useState(false)
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
setAllExpanded(!allExpanded)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" {...props}>
|
||||
|
|
@ -176,8 +188,7 @@ export function SidebarContentPanel({
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
allExpanded={allExpanded}
|
||||
onToggleExpandAll={toggleExpandAll}
|
||||
actions={knowledgeActions}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "agents" && (
|
||||
|
|
@ -232,64 +243,86 @@ function KnowledgeSection({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
allExpanded,
|
||||
onToggleExpandAll,
|
||||
actions,
|
||||
}: {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
allExpanded: boolean
|
||||
onToggleExpandAll: () => void
|
||||
actions: KnowledgeActions
|
||||
}) {
|
||||
const isExpanded = expandedPaths.size > 0
|
||||
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
{ icon: Network, label: "Graph View", action: () => {} },
|
||||
{ icon: ArrowDownAZ, label: "Sort", action: () => {} },
|
||||
]
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-center gap-1 py-1">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onToggleExpandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{allExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarGroup className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-center gap-1 py-1">
|
||||
{quickActions.map((action) => (
|
||||
<Tooltip key={action.label}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={action.action}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
<action.icon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={isExpanded ? actions.collapseAll : actions.expandAll}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronsDownUp className="size-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isExpanded ? "Collapse All" : "Expand All"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SidebarGroupContent className="flex-1">
|
||||
<SidebarMenu>
|
||||
{tree.map((item, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={item}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem onClick={() => actions.createNote()}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.createFolder()}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -299,59 +332,193 @@ function Tree({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
actions,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelect: (path: string, kind: "file" | "dir") => void
|
||||
actions: KnowledgeActions
|
||||
}) {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isDir = item.kind === 'dir'
|
||||
const isExpanded = expandedPaths.has(item.path)
|
||||
const isSelected = selectedPath === item.path
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const isSubmittingRef = React.useRef(false)
|
||||
|
||||
if (!hasChildren) {
|
||||
// For files, strip .md extension for editing
|
||||
const baseName = !isDir && item.name.endsWith('.md')
|
||||
? item.name.slice(0, -3)
|
||||
: item.name
|
||||
const [newName, setNewName] = useState(baseName)
|
||||
|
||||
// Sync newName when baseName changes (e.g., after external rename)
|
||||
React.useEffect(() => {
|
||||
setNewName(baseName)
|
||||
}, [baseName])
|
||||
|
||||
const handleRename = async () => {
|
||||
// Prevent double submission
|
||||
if (isSubmittingRef.current) return
|
||||
isSubmittingRef.current = true
|
||||
|
||||
const trimmedName = newName.trim()
|
||||
if (trimmedName && trimmedName !== baseName) {
|
||||
try {
|
||||
await actions.rename(item.path, trimmedName, isDir)
|
||||
toast('Renamed successfully', 'success')
|
||||
} catch (err) {
|
||||
toast('Failed to rename', 'error')
|
||||
}
|
||||
}
|
||||
setIsRenaming(false)
|
||||
// Reset after a small delay to prevent blur from re-triggering
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await actions.remove(item.path)
|
||||
toast('Moved to trash', 'success')
|
||||
} catch (err) {
|
||||
toast('Failed to delete', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPath = () => {
|
||||
actions.copyPath(item.path)
|
||||
toast('Path copied', 'success')
|
||||
}
|
||||
|
||||
const cancelRename = () => {
|
||||
isSubmittingRef.current = true // Prevent blur from triggering rename
|
||||
setIsRenaming(false)
|
||||
setNewName(baseName) // Reset to original name
|
||||
setTimeout(() => {
|
||||
isSubmittingRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const contextMenuContent = (
|
||||
<ContextMenuContent className="w-48">
|
||||
{isDir && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
|
||||
<FilePlus className="mr-2 size-4" />
|
||||
New Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => actions.createFolder(item.path)}>
|
||||
<FolderPlus className="mr-2 size-4" />
|
||||
New Folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={handleCopyPath}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
|
||||
// Inline rename input
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={isSelected}
|
||||
onClick={() => onSelect(item.path, item.kind)}
|
||||
>
|
||||
<File className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
{isDir ? <Folder className="size-4 shrink-0" /> : <File className="size-4 shrink-0" />}
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
await handleRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Only trigger rename if not already submitting
|
||||
if (!isSubmittingRef.current) {
|
||||
handleRename()
|
||||
}
|
||||
}}
|
||||
className="h-6 text-sm flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDir) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={isSelected}
|
||||
onClick={() => onSelect(item.path, item.kind)}
|
||||
>
|
||||
<File className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<Folder className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.children!.map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onSelect(item.path, item.kind)}
|
||||
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<ChevronRight className="transition-transform size-4" />
|
||||
<Folder className="size-4" />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{(item.children ?? []).map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
250
apps/x/apps/renderer/src/components/ui/context-menu.tsx
Normal file
250
apps/x/apps/renderer/src/components/ui/context-menu.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
46
apps/x/apps/renderer/src/components/ui/popover.tsx
Normal file
46
apps/x/apps/renderer/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
Loading…
Add table
Add a link
Reference in a new issue