Skip to content

Commit 63cbe83

Browse files
committed
ability to symlink a vault
1 parent b238089 commit 63cbe83

File tree

11 files changed

+518
-19
lines changed

11 files changed

+518
-19
lines changed

apps/x/apps/main/src/ipc.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ipcMain, BrowserWindow, shell } from 'electron';
1+
import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
22
import { ipc } from '@x/shared';
33
import path from 'node:path';
44
import os from 'node:os';
@@ -26,6 +26,11 @@ import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
2626
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
2727
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
2828
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
29+
import {
30+
addKnowledgeVault,
31+
listKnowledgeVaults,
32+
removeKnowledgeVault,
33+
} from '@x/core/dist/config/knowledge_vaults.js';
2934
import * as composioHandler from './composio-handler.js';
3035
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
3136
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
@@ -318,6 +323,30 @@ export function setupIpcHandlers() {
318323
'workspace:remove': async (_event, args) => {
319324
return workspace.remove(args.path, args.opts);
320325
},
326+
'knowledge:listVaults': async () => {
327+
return { vaults: listKnowledgeVaults() };
328+
},
329+
'knowledge:pickVaultDirectory': async () => {
330+
const result = await dialog.showOpenDialog({
331+
properties: ['openDirectory'],
332+
});
333+
if (result.canceled || result.filePaths.length === 0) {
334+
return { path: null };
335+
}
336+
return { path: result.filePaths[0] };
337+
},
338+
'knowledge:addVault': async (_event, args) => {
339+
const vault = addKnowledgeVault({
340+
name: args.name,
341+
path: args.path,
342+
readOnly: args.readOnly,
343+
});
344+
return { vault };
345+
},
346+
'knowledge:removeVault': async (_event, args) => {
347+
const removed = removeKnowledgeVault(args.nameOrMountPath);
348+
return { removed };
349+
},
321350
'mcp:listTools': async (_event, args) => {
322351
return mcpCore.listTools(args.serverName, args.cursor);
323352
},

apps/x/apps/renderer/src/App.tsx

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ import {
4545
} from "@/components/ui/sidebar"
4646
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
4747
import { Toaster } from "@/components/ui/sonner"
48+
import {
49+
Dialog,
50+
DialogContent,
51+
DialogDescription,
52+
DialogFooter,
53+
DialogHeader,
54+
DialogTitle,
55+
} from "@/components/ui/dialog"
56+
import { Input } from "@/components/ui/input"
4857
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
4958
import { OnboardingModal } from '@/components/onboarding-modal'
5059
import { SearchDialog } from '@/components/search-dialog'
@@ -356,6 +365,10 @@ function App() {
356365
const [tree, setTree] = useState<TreeNode[]>([])
357366
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
358367
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
368+
const [isAddVaultOpen, setIsAddVaultOpen] = useState(false)
369+
const [pendingVaultPath, setPendingVaultPath] = useState<string | null>(null)
370+
const [pendingVaultName, setPendingVaultName] = useState<string>('')
371+
const [isAddingVault, setIsAddingVault] = useState(false)
359372
const [isGraphOpen, setIsGraphOpen] = useState(false)
360373
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
361374
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@@ -2186,6 +2199,62 @@ function App() {
21862199
setExpandedPaths(newExpanded)
21872200
}
21882201

2202+
// Handle sidebar section changes - switch to chat view for tasks
2203+
const handleSectionChange = useCallback((section: ActiveSection) => {
2204+
if (section === 'tasks') {
2205+
if (selectedBackgroundTask) return
2206+
if (selectedPath || isGraphOpen) {
2207+
void navigateToView({ type: 'chat', runId })
2208+
}
2209+
}
2210+
}, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
2211+
2212+
const deriveVaultName = useCallback((vaultPath: string) => {
2213+
const trimmed = vaultPath.replace(/[\\/]+$/, '')
2214+
const parts = trimmed.split(/[/\\]/)
2215+
return parts[parts.length - 1] || 'Folder'
2216+
}, [])
2217+
2218+
const handleAddVault = useCallback(async () => {
2219+
try {
2220+
const result = await window.ipc.invoke('knowledge:pickVaultDirectory', null)
2221+
if (!result.path) return
2222+
const defaultName = deriveVaultName(result.path)
2223+
setPendingVaultPath(result.path)
2224+
setPendingVaultName(defaultName)
2225+
setIsAddVaultOpen(true)
2226+
} catch (err) {
2227+
console.error('Failed to pick vault directory:', err)
2228+
toast('Failed to open folder picker')
2229+
}
2230+
}, [deriveVaultName])
2231+
2232+
const handleConfirmAddVault = useCallback(async () => {
2233+
if (!pendingVaultPath) return
2234+
const name = pendingVaultName.trim()
2235+
if (!name) {
2236+
toast('Folder name is required')
2237+
return
2238+
}
2239+
setIsAddingVault(true)
2240+
try {
2241+
await window.ipc.invoke('knowledge:addVault', {
2242+
path: pendingVaultPath,
2243+
name,
2244+
readOnly: false,
2245+
})
2246+
setIsAddVaultOpen(false)
2247+
setPendingVaultPath(null)
2248+
setPendingVaultName('')
2249+
loadDirectory().then(setTree)
2250+
toast(`Added knowledge folder "${name}"`)
2251+
} catch (err) {
2252+
console.error('Failed to add vault:', err)
2253+
toast('Failed to add knowledge folder')
2254+
} finally {
2255+
setIsAddingVault(false)
2256+
}
2257+
}, [loadDirectory, pendingVaultName, pendingVaultPath])
21892258
// Knowledge quick actions
21902259
const knowledgeFiles = React.useMemo(() => {
21912260
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
@@ -2278,6 +2347,9 @@ function App() {
22782347
throw err
22792348
}
22802349
},
2350+
addVault: () => {
2351+
void handleAddVault()
2352+
},
22812353
createFolder: async (parentPath: string = 'knowledge') => {
22822354
try {
22832355
await window.ipc.invoke('workspace:mkdir', {
@@ -2289,6 +2361,17 @@ function App() {
22892361
throw err
22902362
}
22912363
},
2364+
unlinkVault: async (mountPath: string) => {
2365+
try {
2366+
await window.ipc.invoke('knowledge:removeVault', { nameOrMountPath: mountPath })
2367+
if (selectedPath && (selectedPath === mountPath || selectedPath.startsWith(`${mountPath}/`))) {
2368+
setSelectedPath(null)
2369+
}
2370+
} catch (err) {
2371+
console.error('Failed to unlink knowledge folder:', err)
2372+
throw err
2373+
}
2374+
},
22922375
openGraph: () => {
22932376
void navigateToView({ type: 'graph' })
22942377
},
@@ -2355,7 +2438,19 @@ function App() {
23552438
onOpenInNewTab: (path: string) => {
23562439
openFileInNewTab(path)
23572440
},
2358-
}), [tree, selectedPath, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
2441+
}), [
2442+
tree,
2443+
selectedPath,
2444+
workspaceRoot,
2445+
collectDirPaths,
2446+
navigateToFile,
2447+
navigateToView,
2448+
handleAddVault,
2449+
openFileInNewTab,
2450+
fileTabs,
2451+
closeFileTab,
2452+
removeEditorCacheForPath,
2453+
])
23592454

23602455
// Handler for when a voice note is created/updated
23612456
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@@ -3089,6 +3184,50 @@ function App() {
30893184
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
30903185
/>
30913186
</SidebarSectionProvider>
3187+
<Dialog
3188+
open={isAddVaultOpen}
3189+
onOpenChange={(open) => {
3190+
if (!open) {
3191+
setIsAddVaultOpen(false)
3192+
setPendingVaultPath(null)
3193+
setPendingVaultName('')
3194+
}
3195+
}}
3196+
>
3197+
<DialogContent>
3198+
<DialogHeader>
3199+
<DialogTitle>Add Knowledge Folder</DialogTitle>
3200+
<DialogDescription>
3201+
Link an existing folder into knowledge.
3202+
</DialogDescription>
3203+
</DialogHeader>
3204+
<div className="space-y-3">
3205+
<div className="text-xs text-muted-foreground break-all">
3206+
{pendingVaultPath ?? 'No folder selected'}
3207+
</div>
3208+
<Input
3209+
value={pendingVaultName}
3210+
onChange={(event) => setPendingVaultName(event.target.value)}
3211+
placeholder="Folder name"
3212+
/>
3213+
</div>
3214+
<DialogFooter>
3215+
<Button
3216+
variant="ghost"
3217+
onClick={() => {
3218+
setIsAddVaultOpen(false)
3219+
setPendingVaultPath(null)
3220+
setPendingVaultName('')
3221+
}}
3222+
>
3223+
Cancel
3224+
</Button>
3225+
<Button onClick={handleConfirmAddVault} disabled={isAddingVault}>
3226+
{isAddingVault ? 'Adding...' : 'Add Folder'}
3227+
</Button>
3228+
</DialogFooter>
3229+
</DialogContent>
3230+
</Dialog>
30923231
<Toaster />
30933232
<OnboardingModal
30943233
open={showOnboarding}

apps/x/apps/renderer/src/components/sidebar-content.tsx

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Pencil,
1919
Plug,
2020
LoaderIcon,
21+
PlusCircle,
2122
Settings,
2223
Square,
2324
Trash2,
@@ -100,6 +101,8 @@ interface TreeNode {
100101
type KnowledgeActions = {
101102
createNote: (parentPath?: string) => void
102103
createFolder: (parentPath?: string) => void
104+
addVault: () => void
105+
unlinkVault: (mountPath: string) => Promise<void>
103106
openGraph: () => void
104107
expandAll: () => void
105108
collapseAll: () => void
@@ -824,6 +827,7 @@ function KnowledgeSection({
824827
const quickActions = [
825828
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
826829
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
830+
{ icon: PlusCircle, label: "Add Knowledge Folder", action: () => actions.addVault() },
827831
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
828832
]
829833

@@ -911,6 +915,8 @@ function Tree({
911915
const isDir = item.kind === 'dir'
912916
const isExpanded = expandedPaths.has(item.path)
913917
const isSelected = selectedPath === item.path
918+
const [isSymlinkMount, setIsSymlinkMount] = useState(false)
919+
const [symlinkChecked, setSymlinkChecked] = useState(false)
914920
const [isRenaming, setIsRenaming] = useState(false)
915921
const isSubmittingRef = React.useRef(false)
916922

@@ -925,6 +931,20 @@ function Tree({
925931
setNewName(baseName)
926932
}, [baseName])
927933

934+
const ensureSymlinkStatus = async () => {
935+
if (symlinkChecked || !isDir) return isSymlinkMount
936+
try {
937+
const stat = await window.ipc.invoke('workspace:stat', { path: item.path })
938+
const isLink = Boolean(stat.isSymlink)
939+
setIsSymlinkMount(isLink)
940+
setSymlinkChecked(true)
941+
return isLink
942+
} catch {
943+
setSymlinkChecked(true)
944+
return false
945+
}
946+
}
947+
928948
const handleRename = async () => {
929949
// Prevent double submission
930950
if (isSubmittingRef.current) return
@@ -933,6 +953,11 @@ function Tree({
933953
const trimmedName = newName.trim()
934954
if (trimmedName && trimmedName !== baseName) {
935955
try {
956+
if (await ensureSymlinkStatus()) {
957+
toast('Linked folders cannot be renamed here', 'error')
958+
setIsRenaming(false)
959+
return
960+
}
936961
await actions.rename(item.path, trimmedName, isDir)
937962
toast('Renamed successfully', 'success')
938963
} catch (err) {
@@ -947,11 +972,18 @@ function Tree({
947972
}
948973

949974
const handleDelete = async () => {
975+
let isLink = false
950976
try {
951-
await actions.remove(item.path)
952-
toast('Moved to trash', 'success')
977+
isLink = await ensureSymlinkStatus()
978+
if (isLink) {
979+
await actions.unlinkVault(item.path)
980+
toast('Unlinked knowledge folder', 'success')
981+
} else {
982+
await actions.remove(item.path)
983+
toast('Moved to trash', 'success')
984+
}
953985
} catch (err) {
954-
toast('Failed to delete', 'error')
986+
toast(isLink ? 'Failed to unlink' : 'Failed to delete', 'error')
955987
}
956988
}
957989

@@ -998,17 +1030,32 @@ function Tree({
9981030
Copy Path
9991031
</ContextMenuItem>
10001032
<ContextMenuSeparator />
1001-
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
1002-
<Pencil className="mr-2 size-4" />
1003-
Rename
1004-
</ContextMenuItem>
1033+
{!isSymlinkMount && (
1034+
<ContextMenuItem onClick={async () => {
1035+
if (await ensureSymlinkStatus()) {
1036+
toast('Linked folders cannot be renamed here', 'error')
1037+
return
1038+
}
1039+
setNewName(baseName)
1040+
isSubmittingRef.current = false
1041+
setIsRenaming(true)
1042+
}}>
1043+
<Pencil className="mr-2 size-4" />
1044+
Rename
1045+
</ContextMenuItem>
1046+
)}
10051047
<ContextMenuItem variant="destructive" onClick={handleDelete}>
10061048
<Trash2 className="mr-2 size-4" />
1007-
Delete
1049+
{isSymlinkMount ? 'Unlink Knowledge Folder' : 'Delete'}
10081050
</ContextMenuItem>
10091051
</ContextMenuContent>
10101052
)
10111053

1054+
const handleContextMenuOpenChange = (open: boolean) => {
1055+
if (!open || symlinkChecked || !isDir) return
1056+
void ensureSymlinkStatus()
1057+
}
1058+
10121059
// Inline rename input
10131060
if (isRenaming) {
10141061
return (
@@ -1043,7 +1090,7 @@ function Tree({
10431090

10441091
if (!isDir) {
10451092
return (
1046-
<ContextMenu>
1093+
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
10471094
<ContextMenuTrigger asChild>
10481095
<SidebarMenuItem className="group/file-item">
10491096
<SidebarMenuButton
@@ -1068,7 +1115,7 @@ function Tree({
10681115
}
10691116

10701117
return (
1071-
<ContextMenu>
1118+
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
10721119
<ContextMenuTrigger asChild>
10731120
<SidebarMenuItem>
10741121
<Collapsible

0 commit comments

Comments
 (0)