Skip to content

Commit 3d59545

Browse files
committed
ability to symlink a vault
1 parent 728857d commit 3d59545

File tree

11 files changed

+497
-19
lines changed

11 files changed

+497
-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';
@@ -317,6 +322,30 @@ export function setupIpcHandlers() {
317322
'workspace:remove': async (_event, args) => {
318323
return workspace.remove(args.path, args.opts);
319324
},
325+
'knowledge:listVaults': async () => {
326+
return { vaults: listKnowledgeVaults() };
327+
},
328+
'knowledge:pickVaultDirectory': async () => {
329+
const result = await dialog.showOpenDialog({
330+
properties: ['openDirectory'],
331+
});
332+
if (result.canceled || result.filePaths.length === 0) {
333+
return { path: null };
334+
}
335+
return { path: result.filePaths[0] };
336+
},
337+
'knowledge:addVault': async (_event, args) => {
338+
const vault = addKnowledgeVault({
339+
name: args.name,
340+
path: args.path,
341+
readOnly: args.readOnly,
342+
});
343+
return { vault };
344+
},
345+
'knowledge:removeVault': async (_event, args) => {
346+
const removed = removeKnowledgeVault(args.nameOrMountPath);
347+
return { removed };
348+
},
320349
'mcp:listTools': async (_event, args) => {
321350
return mcpCore.listTools(args.serverName, args.cursor);
322351
},

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

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ import {
4848
} from "@/components/ui/sidebar"
4949
import { TooltipProvider } from "@/components/ui/tooltip"
5050
import { Toaster } from "@/components/ui/sonner"
51+
import {
52+
Dialog,
53+
DialogContent,
54+
DialogDescription,
55+
DialogFooter,
56+
DialogHeader,
57+
DialogTitle,
58+
} from "@/components/ui/dialog"
59+
import { Input } from "@/components/ui/input"
5160
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
5261
import { OnboardingModal } from '@/components/onboarding-modal'
5362
import { BackgroundTaskDetail } from '@/components/background-task-detail'
@@ -606,6 +615,10 @@ function App() {
606615
const [tree, setTree] = useState<TreeNode[]>([])
607616
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
608617
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
618+
const [isAddVaultOpen, setIsAddVaultOpen] = useState(false)
619+
const [pendingVaultPath, setPendingVaultPath] = useState<string | null>(null)
620+
const [pendingVaultName, setPendingVaultName] = useState<string>('')
621+
const [isAddingVault, setIsAddingVault] = useState(false)
609622
const [isGraphOpen, setIsGraphOpen] = useState(false)
610623
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
611624
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@@ -1854,6 +1867,53 @@ function App() {
18541867
}
18551868
}, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
18561869

1870+
const deriveVaultName = useCallback((vaultPath: string) => {
1871+
const trimmed = vaultPath.replace(/[\\/]+$/, '')
1872+
const parts = trimmed.split(/[/\\]/)
1873+
return parts[parts.length - 1] || 'Vault'
1874+
}, [])
1875+
1876+
const handleAddVault = useCallback(async () => {
1877+
try {
1878+
const result = await window.ipc.invoke('knowledge:pickVaultDirectory', null)
1879+
if (!result.path) return
1880+
const defaultName = deriveVaultName(result.path)
1881+
setPendingVaultPath(result.path)
1882+
setPendingVaultName(defaultName)
1883+
setIsAddVaultOpen(true)
1884+
} catch (err) {
1885+
console.error('Failed to pick vault directory:', err)
1886+
toast('Failed to open folder picker')
1887+
}
1888+
}, [deriveVaultName])
1889+
1890+
const handleConfirmAddVault = useCallback(async () => {
1891+
if (!pendingVaultPath) return
1892+
const name = pendingVaultName.trim()
1893+
if (!name) {
1894+
toast('Vault name is required')
1895+
return
1896+
}
1897+
setIsAddingVault(true)
1898+
try {
1899+
await window.ipc.invoke('knowledge:addVault', {
1900+
path: pendingVaultPath,
1901+
name,
1902+
readOnly: false,
1903+
})
1904+
setIsAddVaultOpen(false)
1905+
setPendingVaultPath(null)
1906+
setPendingVaultName('')
1907+
loadDirectory().then(setTree)
1908+
toast(`Added knowledge folder "${name}"`)
1909+
} catch (err) {
1910+
console.error('Failed to add vault:', err)
1911+
toast('Failed to add knowledge folder')
1912+
} finally {
1913+
setIsAddingVault(false)
1914+
}
1915+
}, [loadDirectory, pendingVaultName, pendingVaultPath])
1916+
18571917
// Knowledge quick actions
18581918
const knowledgeFiles = React.useMemo(() => {
18591919
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
@@ -1946,6 +2006,9 @@ function App() {
19462006
throw err
19472007
}
19482008
},
2009+
addVault: () => {
2010+
void handleAddVault()
2011+
},
19492012
createFolder: async (parentPath: string = 'knowledge') => {
19502013
try {
19512014
await window.ipc.invoke('workspace:mkdir', {
@@ -1957,6 +2020,17 @@ function App() {
19572020
throw err
19582021
}
19592022
},
2023+
unlinkVault: async (mountPath: string) => {
2024+
try {
2025+
await window.ipc.invoke('knowledge:removeVault', { nameOrMountPath: mountPath })
2026+
if (selectedPath && (selectedPath === mountPath || selectedPath.startsWith(`${mountPath}/`))) {
2027+
setSelectedPath(null)
2028+
}
2029+
} catch (err) {
2030+
console.error('Failed to unlink knowledge folder:', err)
2031+
throw err
2032+
}
2033+
},
19602034
openGraph: () => {
19612035
void navigateToView({ type: 'graph' })
19622036
},
@@ -1989,7 +2063,7 @@ function App() {
19892063
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
19902064
navigator.clipboard.writeText(fullPath)
19912065
},
1992-
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView])
2066+
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView, handleAddVault])
19932067

19942068
// Handler for when a voice note is created/updated
19952069
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@@ -2569,6 +2643,50 @@ function App() {
25692643
)}
25702644
</div>
25712645
</SidebarSectionProvider>
2646+
<Dialog
2647+
open={isAddVaultOpen}
2648+
onOpenChange={(open) => {
2649+
if (!open) {
2650+
setIsAddVaultOpen(false)
2651+
setPendingVaultPath(null)
2652+
setPendingVaultName('')
2653+
}
2654+
}}
2655+
>
2656+
<DialogContent>
2657+
<DialogHeader>
2658+
<DialogTitle>Add Knowledge Folder</DialogTitle>
2659+
<DialogDescription>
2660+
Link an existing folder into knowledge.
2661+
</DialogDescription>
2662+
</DialogHeader>
2663+
<div className="space-y-3">
2664+
<div className="text-xs text-muted-foreground break-all">
2665+
{pendingVaultPath ?? 'No folder selected'}
2666+
</div>
2667+
<Input
2668+
value={pendingVaultName}
2669+
onChange={(event) => setPendingVaultName(event.target.value)}
2670+
placeholder="Folder name"
2671+
/>
2672+
</div>
2673+
<DialogFooter>
2674+
<Button
2675+
variant="ghost"
2676+
onClick={() => {
2677+
setIsAddVaultOpen(false)
2678+
setPendingVaultPath(null)
2679+
setPendingVaultName('')
2680+
}}
2681+
>
2682+
Cancel
2683+
</Button>
2684+
<Button onClick={handleConfirmAddVault} disabled={isAddingVault}>
2685+
{isAddingVault ? 'Adding...' : 'Add Folder'}
2686+
</Button>
2687+
</DialogFooter>
2688+
</DialogContent>
2689+
</Dialog>
25722690
<Toaster />
25732691
<OnboardingModal
25742692
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
@@ -17,6 +17,7 @@ import {
1717
Pencil,
1818
Plug,
1919
LoaderIcon,
20+
PlusCircle,
2021
Settings,
2122
Square,
2223
Trash2,
@@ -99,6 +100,8 @@ interface TreeNode {
99100
type KnowledgeActions = {
100101
createNote: (parentPath?: string) => void
101102
createFolder: (parentPath?: string) => void
103+
addVault: () => void
104+
unlinkVault: (mountPath: string) => Promise<void>
102105
openGraph: () => void
103106
expandAll: () => void
104107
collapseAll: () => void
@@ -821,6 +824,7 @@ function KnowledgeSection({
821824
const quickActions = [
822825
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
823826
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
827+
{ icon: PlusCircle, label: "Add Knowledge Folder", action: () => actions.addVault() },
824828
{ icon: Network, label: "Graph View", action: () => actions.openGraph() },
825829
]
826830

@@ -908,6 +912,8 @@ function Tree({
908912
const isDir = item.kind === 'dir'
909913
const isExpanded = expandedPaths.has(item.path)
910914
const isSelected = selectedPath === item.path
915+
const [isSymlinkMount, setIsSymlinkMount] = useState(false)
916+
const [symlinkChecked, setSymlinkChecked] = useState(false)
911917
const [isRenaming, setIsRenaming] = useState(false)
912918
const isSubmittingRef = React.useRef(false)
913919

@@ -922,6 +928,20 @@ function Tree({
922928
setNewName(baseName)
923929
}, [baseName])
924930

931+
const ensureSymlinkStatus = async () => {
932+
if (symlinkChecked || !isDir) return isSymlinkMount
933+
try {
934+
const stat = await window.ipc.invoke('workspace:stat', { path: item.path })
935+
const isLink = Boolean(stat.isSymlink)
936+
setIsSymlinkMount(isLink)
937+
setSymlinkChecked(true)
938+
return isLink
939+
} catch {
940+
setSymlinkChecked(true)
941+
return false
942+
}
943+
}
944+
925945
const handleRename = async () => {
926946
// Prevent double submission
927947
if (isSubmittingRef.current) return
@@ -930,6 +950,11 @@ function Tree({
930950
const trimmedName = newName.trim()
931951
if (trimmedName && trimmedName !== baseName) {
932952
try {
953+
if (await ensureSymlinkStatus()) {
954+
toast('Linked folders cannot be renamed here', 'error')
955+
setIsRenaming(false)
956+
return
957+
}
933958
await actions.rename(item.path, trimmedName, isDir)
934959
toast('Renamed successfully', 'success')
935960
} catch (err) {
@@ -944,11 +969,18 @@ function Tree({
944969
}
945970

946971
const handleDelete = async () => {
972+
let isLink = false
947973
try {
948-
await actions.remove(item.path)
949-
toast('Moved to trash', 'success')
974+
isLink = await ensureSymlinkStatus()
975+
if (isLink) {
976+
await actions.unlinkVault(item.path)
977+
toast('Unlinked knowledge folder', 'success')
978+
} else {
979+
await actions.remove(item.path)
980+
toast('Moved to trash', 'success')
981+
}
950982
} catch (err) {
951-
toast('Failed to delete', 'error')
983+
toast(isLink ? 'Failed to unlink' : 'Failed to delete', 'error')
952984
}
953985
}
954986

@@ -986,17 +1018,32 @@ function Tree({
9861018
Copy Path
9871019
</ContextMenuItem>
9881020
<ContextMenuSeparator />
989-
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
990-
<Pencil className="mr-2 size-4" />
991-
Rename
992-
</ContextMenuItem>
1021+
{!isSymlinkMount && (
1022+
<ContextMenuItem onClick={async () => {
1023+
if (await ensureSymlinkStatus()) {
1024+
toast('Linked folders cannot be renamed here', 'error')
1025+
return
1026+
}
1027+
setNewName(baseName)
1028+
isSubmittingRef.current = false
1029+
setIsRenaming(true)
1030+
}}>
1031+
<Pencil className="mr-2 size-4" />
1032+
Rename
1033+
</ContextMenuItem>
1034+
)}
9931035
<ContextMenuItem variant="destructive" onClick={handleDelete}>
9941036
<Trash2 className="mr-2 size-4" />
995-
Delete
1037+
{isSymlinkMount ? 'Unlink Knowledge Folder' : 'Delete'}
9961038
</ContextMenuItem>
9971039
</ContextMenuContent>
9981040
)
9991041

1042+
const handleContextMenuOpenChange = (open: boolean) => {
1043+
if (!open || symlinkChecked || !isDir) return
1044+
void ensureSymlinkStatus()
1045+
}
1046+
10001047
// Inline rename input
10011048
if (isRenaming) {
10021049
return (
@@ -1031,7 +1078,7 @@ function Tree({
10311078

10321079
if (!isDir) {
10331080
return (
1034-
<ContextMenu>
1081+
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
10351082
<ContextMenuTrigger asChild>
10361083
<SidebarMenuItem>
10371084
<SidebarMenuButton
@@ -1048,7 +1095,7 @@ function Tree({
10481095
}
10491096

10501097
return (
1051-
<ContextMenu>
1098+
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
10521099
<ContextMenuTrigger asChild>
10531100
<SidebarMenuItem>
10541101
<Collapsible

0 commit comments

Comments
 (0)