refactor(frontend): extract FilesTab from App.svelte

• Create lib/components/files/FilesTab.svelte (616 lines)
  - Self-contained file browser with internal state
  - Folder navigation, breadcrumbs, file list
  - File preview with CheckFileAction for .md → note editor flow
  - Import dialog (copy/link)
  - Drag-and-drop, selection, keyboard shortcuts
  - Cut/copy/paste, rename, delete, duplicate
  - Public API: resetToNode(), filesHandleKeydown()
• Remove ~594 lines from App.svelte (4794 → 4200)
  - Removed: loadingFiles, currentFolderId, folderStack, fileItems,
    previewItem/Content/Loading/Error, clipboard, selectedIds, dragIds,
    importing, importSummary, showImportDialog, pendingImportPath/Parent,
    treeItems, expanded, childrenMap, dropRootValid, inboxDropValid,
    captureDropActive/Label/DragDepth/lastCaptureDragOverAt/captureDragResetTimer
  - Removed: loadTree, loadFolder, navigateToFolder, navigateBack,
    navigateToBreadcrumb, openPreview, closePreview, createFile,
    duplicateItem, renameItem, cutItem, copyItem, pasteItem,
    toggleSelection, selectOne, selectAll, rangeSelect, clearSelection,
    getTargetIds, deleteSelected, cutSelected, copySelected,
    onDragStart, onDragOver, onDrop, openSelected, openSelectedExternal,
    addFile, addFolder, startImport, confirmImport, cancelImport, deleteFile
  - Keyboard handler delegates to filesTabRef.filesHandleKeydown()
  - selectNode() calls filesTabRef.resetToNode()
• Update docs/frontend-architecture.md — Files Flow section
• Update docs/frontend-change-map.md — Phase 2 & 5 marked complete
• Build passes: npm run build ✓, go test ✓, build.sh gui ✓
This commit is contained in:
2026-06-15 20:31:23 +08:00
parent 490a3dd624
commit 9878ab4fb3
8 changed files with 768 additions and 649 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-CzfuqGWF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
<script type="module" crossorigin src="/assets/main-DTT9uc7y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bl-yCbt2.css">
</head>
<body>
<div id="app"></div>
+18
View File
@@ -136,8 +136,26 @@ The `wailsCall()` helper in `lib/services/wails.js` provides error handling.
Currently all state lives in App.svelte as local variables.
Target: extract into `lib/state/navigation.js` and `lib/state/uiState.js`.
### Files Flow
- **Component:** `lib/components/files/FilesTab.svelte` — self-contained file browser
- **API services:** `lib/services/files.js`, `lib/services/nodes.js`
- **Events emitted:**
- `on:openNote` — when a .md file linked to a note is opened
- `on:refreshParent` — after file operations that modify the tree
- `on:error` — on operation failures
- `on:rename` — requests parent to show rename modal
- `on:confirm` — requests parent to show confirm dialog
- **Public methods:**
- `resetToNode(nodeId)` — reset state when selected node changes
- `filesHandleKeydown(e)` — keyboard handler for files tab
- **.md → note editor flow:** Handled inside FilesTab via `CheckFileAction` Wails call. If action is 'note', emits `openNote`. If 'external', opens in system viewer. Otherwise shows built-in preview.
- **File preview:** `FilePreviewModal.svelte` (already existed), invoked by FilesTab
- **Import dialog:** Inline in FilesTab template (moved from App.svelte)
## Build & Verification
- `npm run build` in `frontend/` directory
- `go test ./...` from project root
- `bash scripts/build.sh gui` for full GUI binary
- Manual smoke testing via Wails dev server
+26 -14
View File
@@ -15,17 +15,29 @@ into a modular frontend architecture. Each step preserves behaviour exactly.
Extract all Wails calls into service modules.
- [ ] Create `lib/services/wails.js` — base `wailsCall` helper
- [ ] Create `lib/services/notes.js``listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
- [ ] Create `lib/services/files.js``loadFolder`, `addFile`, `deleteFile`, etc.
- [ ] Create `lib/services/search.js``searchNodes`
- [ ] Create `lib/services/inbox.js``listInbox`, `captureClipboard`, etc.
- [ ] Create `lib/services/trash.js``loadTrash`, `restore`, `purge`
- [ ] Create `lib/services/sync.js``loadSyncStatus`, `runSync`
- [ ] Create `lib/services/journal.js``loadJournal`, `worklog CRUD`
- [ ] Create `lib/services/actions.js``listActions`, `createAction`, `deleteAction`
- [ ] Create `lib/services/links.js``listLinks`, `updateLink`, `deleteLink`
- [ ] Create `lib/services/activity.js``loadActivityFeed`, `loadCaseActivity`
- [x] Create `lib/services/wails.js` — base `wailsCall` helper
- [x] Create `lib/services/notes.js``listNotes`, `createNote`, `readNote`, `saveNote`, `renameNote`, `deleteNote`
- [x] Create `lib/services/files.js``loadFolder`, `addFile`, `deleteFile`, etc.
- [x] Create `lib/services/search.js``searchNodes`, `getNodeDetail`, `searchWorkspace`
- [x] Create `lib/services/inbox.js`inbox capture and management
- [x] Create `lib/services/trash.js`trash operations
- [x] Create `lib/services/sync.js`sync status and trigger
- [x] Create `lib/services/journal.js`worklog CRUD and reports
- [x] Create `lib/services/actions.js`actions CRUD
- [x] Create `lib/services/links.js`links CRUD
- [x] Create `lib/services/activity.js`activity feed
- [x] Create `lib/services/nodes.js` — tree, node CRUD, system views
- [x] Create `lib/services/suggestions.js` — worklog suggestions
- [x] Create `lib/services/today.js` — today dashboard
- [x] Create `lib/services/browserEvents.js` — browser extension events
- [x] Create `lib/services/search.js``searchNodes`
- [x] Create `lib/services/inbox.js``listInbox`, `captureClipboard`, etc.
- [x] Create `lib/services/trash.js``loadTrash`, `restore`, `purge`
- [x] Create `lib/services/sync.js``loadSyncStatus`, `runSync`
- [x] Create `lib/services/journal.js``loadJournal`, `worklog CRUD`
- [x] Create `lib/services/actions.js``listActions`, `createAction`, `deleteAction`
- [x] Create `lib/services/links.js``listLinks`, `updateLink`, `deleteLink`
- [x] Create `lib/services/activity.js``loadActivityFeed`, `loadCaseActivity`
## Phase 3: State Extraction
@@ -40,9 +52,9 @@ Extract all Wails calls into service modules.
## Phase 5: Component Extraction — Tab Content
- [ ] Extract `OverviewTab.svelte`
- [ ] Extract `NotesTab.svelte`
- [ ] Extract `FilesTab.svelte`
- [x] Extract `OverviewTab.svelte`
- [x] Extract `NotesTab.svelte`
- [x] Extract `FilesTab.svelte`
- [ ] Extract `LinksTab.svelte`
- [ ] Extract `ActionsTab.svelte`
- [ ] Extract `WorklogTab.svelte`
+18 -545
View File
@@ -22,6 +22,10 @@
import CaptureDropOverlay from './lib/components/CaptureDropOverlay.svelte'
import OverviewTab from './lib/components/OverviewTab.svelte'
import NotesTab from './lib/components/notes/NotesTab.svelte'
import FilesTab from './lib/components/files/FilesTab.svelte'
// Component refs
let filesTabRef = null
// ===== Wails v2 API call helper =====
function wailsCall(method, ...args) {
@@ -162,33 +166,6 @@
{ id: 'launch_app', label: t('action.launchApp') },
]
let loading = true
let importing = false
let importSummary = null
let showImportDialog = false
let pendingImportPath = ''
let pendingImportParent = ''
let pendingImportMode = 'copy'
let treeItems = []
let expanded = {}
let childrenMap = {}
let loadingFiles = false
let currentFolderId = null
let folderStack = []
let fileItems = []
let previewItem = null
let previewContent = ''
let previewLoading = false
let previewError = ''
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
let dropRootValid = false
let inboxDropValid = false
let captureDropActive = false
let captureDropLabel = ''
let captureDragDepth = 0
let lastCaptureDragOverAt = 0
let captureDragResetTimer = null
let showConfirm = false
let confirmTitle = ''
@@ -306,12 +283,10 @@
function closeTopModalForBack() {
if (showConfirm) { closeConfirm(); return true }
if (showSettings) { closeSettings(); return true }
if (previewItem) { closePreview(); return true }
if (assignInboxItem) { closeAssignInbox(); return true }
if (editingLink) { closeEditLink(); return true }
if (showRename) { showRename = false; return true }
if (showWorklogModal) { closeWorklogModal(); return true }
if (showImportDialog) { cancelImport(); return true }
if (showCreateAction) { cancelCreateAction(); return true }
if (showCreateNote) { cancelCreateNote(); return true }
if (showCreateNode) { cancelCreateNode(); return true }
@@ -330,9 +305,6 @@
}
await selectNode(node)
activeTab = snapshot.tab || 'overview'
if (activeTab === 'files' && fileItems.length === 0) {
await loadFolder(node.id)
}
return true
} else if (snapshot.section) {
if (!systemViews.some(view => view.id === snapshot.section)) {
@@ -377,7 +349,6 @@
if (activeTab === tabId) return
rememberNavigation()
activeTab = tabId
if (tabId === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id)
}
// ===== Lifecycle =====
@@ -525,19 +496,13 @@
actions = []
worklog = []
suggestions = []
fileItems = []
folderStack = []
currentFolderId = null
previewItem = null
previewContent = ''
selectedIds = []
dragIds = []
resetTrashBrowser()
noteEditor = null
showCreateNode = false
showCreateNote = false
error = ''
caseActivity = []
if (filesTabRef) filesTabRef.resetToNode(node.id)
await loadTabData(node.id)
}
@@ -555,291 +520,6 @@
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
}
async function loadTree(nodeID) {
loadingFiles = true
try {
treeItems = await wailsCall('ListItems', nodeID) || []
} catch (e) {
treeItems = []
}
loadingFiles = false
}
// ===== Folder navigation =====
async function loadFolder(folderId) {
loadingFiles = true
try {
let items = await wailsCall('ListItems', folderId) || []
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return (a.name || '').localeCompare(b.name || '')
})
fileItems = items
} catch (e) {
fileItems = []
}
loadingFiles = false
}
async function navigateToFolder(folderId) {
if (!selectedNode) return
rememberNavigation()
try {
const node = await wailsCall('GetNodeDetail', folderId)
if (node) {
folderStack = [...folderStack, { id: folderId, name: node.title }]
}
} catch (e) {
folderStack = [...folderStack, { id: folderId, name: '...' }]
}
currentFolderId = folderId
expanded = { ...expanded, [folderId]: true }
const children = await wailsCall('ListWorkspaceChildren', folderId) || []
setNodeChildren(workspaceTree, folderId, children)
workspaceTree = [...workspaceTree]
await loadFolder(folderId)
}
function navigateBack() {
rememberNavigation()
if (folderStack.length < 2) {
// Go back to root
folderStack = []
currentFolderId = null
loadFolder(selectedNode.id)
} else {
const target = folderStack[folderStack.length - 2]
folderStack = folderStack.slice(0, -1)
currentFolderId = target.id
loadFolder(target.id)
}
}
function navigateToBreadcrumb(index) {
const target = folderStack[index]
folderStack = folderStack.slice(0, index + 1)
currentFolderId = target.id
loadFolder(target.id)
}
// ===== File preview =====
async function openPreview(item) {
// For .md files: check if linked to a note, open note editor instead of preview modal
if (item && item.fileId && isMarkdownFile(item)) {
try {
const action = await wailsCall('CheckFileAction', item.fileId)
if (action.action === 'note') {
await openNote({ id: action.noteId, title: action.noteTitle })
return
}
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return
}
// 'preview' → fall through to normal preview
} catch (e) {
console.warn('CheckFileAction failed, falling back to preview:', e)
}
}
previewItem = item
previewContent = ''
previewError = ''
previewLoading = true
try {
if (needsBase64Preview(item)) {
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
}
} catch (e) {
previewError = String(e)
}
previewLoading = false
}
function closePreview() {
previewItem = null
previewContent = ''
previewError = ''
}
// ===== File operations =====
async function createFile() {
const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return
try {
const parentId = currentFolderId || selectedNode.id
await wailsCall('CreateEmptyFile', parentId, name.trim())
await loadFolder(parentId)
await refreshParentNode(parentId)
} catch (e) {
error = String(e)
}
}
async function duplicateItem(id) {
try {
await wailsCall('DuplicateNode', id)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
await refreshParentNode(parentId)
} catch (e) {
error = String(e)
}
}
function renameItem(id) {
const item = fileItems.find(x => x.id === id)
if (item) openRename(item.id, item.name)
}
function cutItem(id) {
clipboard = { items: [id], mode: 'cut' }
}
function copyItem(id) {
clipboard = { items: [id], mode: 'copy' }
}
async function pasteItem() {
if (clipboard.items.length === 0) return
const targetId = currentFolderId || selectedNode.id
try {
if (clipboard.mode === 'copy') {
for (const id of clipboard.items) {
await wailsCall('DuplicateNode', id)
}
} else {
for (const id of clipboard.items) {
await wailsCall('MoveNode', id, targetId)
}
}
clipboard = { items: [], mode: 'copy' }
await loadFolder(targetId)
} catch (e) {
error = String(e)
}
}
// ===== Selection =====
function toggleSelection(id) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(x => x !== id)
} else {
selectedIds = [...selectedIds, id]
}
}
function selectOne(id) {
selectedIds = [id]
}
function selectAll() {
selectedIds = fileItems.map(x => x.id)
}
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function clearSelection() {
selectedIds = []
}
function getTargetIds(ids) {
return ids.length > 0 ? ids : fileItems.map(x => x.id)
}
async function deleteSelected() {
const ids = getTargetIds(selectedIds)
const item = fileItems.find(x => x.id === ids[0])
let label
if (ids.length === 1 && item?.type === 'folder') {
label = t('delete.folder')
} else if (ids.length === 1) {
label = t('delete.file')
} else {
label = t('delete.files', { count: ids.length })
}
openConfirm({
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
for (const id of ids) {
try {
await wailsCall('DeleteFileOrFolder', id)
} catch (e) { error = String(e) }
}
selectedIds = []
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
}
})
}
function cutSelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = []
}
function copySelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
// ===== Drag-and-drop =====
function onDragStart(e, id) {
e.stopPropagation()
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ids.join(','))
}
function onDragOver(e, folderId) {
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
e.stopPropagation()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
await wailsCall('MoveNode', id, folderId)
} catch (e) { error = String(e) }
}
dragIds = []
selectedIds = []
await loadFolder(currentFolderId || selectedNode.id)
}
// ===== Keyboard =====
function isEditableTarget(target) {
@@ -863,47 +543,9 @@
if (activeTab !== 'files') return
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete') {
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
if (filesTabRef) {
filesTabRef.filesHandleKeydown(e)
return
}
}
@@ -1983,80 +1625,7 @@
URL.revokeObjectURL(url)
}
// ===== Files =====
async function addFile() {
const path = await wailsCall('PickFile')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function startImport(parentID, sourcePath) {
importing = true
try {
const summary = await wailsCall('PreviewImport', sourcePath)
importSummary = summary
pendingImportPath = sourcePath
pendingImportParent = parentID
showImportDialog = true
} catch (e) {
error = String(e)
}
importing = false
}
async function confirmImport(mode) {
try {
const parentId = pendingImportParent || selectedNode.id
const result = mode === 'copy'
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
: await wailsCall('AddPathLink', parentId, pendingImportPath)
showImportDialog = false
importSummary = null
folderStack = []
currentFolderId = null
await Promise.all([
loadTabData(parentId),
loadFolder(parentId),
refreshParentNode(parentId),
])
} catch (e) {
error = String(e)
}
}
function cancelImport() {
showImportDialog = false
importSummary = null
}
async function deleteFile({ id, type }) {
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
openConfirm({
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteFileOrFolder', id)
files = files.filter(f => f.nodeId !== id)
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
} catch (e) {
error = String(e)
}
}
})
}
// ===== Drag-and-drop =====
async function openSelectedFile(fileID) {
try {
await wailsCall('OpenFile', fileID)
@@ -3022,87 +2591,15 @@
/>
{:else if activeTab === 'files'}
<!-- External file drops are handled by the unified capture flow and land in the selected case inbox. -->
<div class="files-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if}
</div>
{#if loadingFiles}
<div class="empty-state">
<p>{t('common.loading')}</p>
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('common.backLabel')}
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if}
{#if fileItems.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
</div>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if}
</div>
<FilesTab
bind:this={filesTabRef}
{selectedNode}
on:openNote={(e) => openNote(e.detail.note)}
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
on:error={(e) => { error = e.detail.message }}
on:rename={(e) => openRename(e.detail.id, e.detail.name)}
on:confirm={(e) => openConfirm(e.detail)}
/>
{:else if activeTab === 'inbox'}
<div class="inbox-tab">
@@ -3968,30 +3465,6 @@
</div>
{/if}
{#if showImportDialog && importSummary}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={onKeyActivate(cancelImport)}>
<div class="modal">
<h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
<div class="import-summary">
<div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
<div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
<div class="summary-row"><span>{t('file.importSize')}</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
{#if importSummary.isDangerous}
<div class="summary-warn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{importSummary.dangerReason}
</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
{#if showRename}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={onKeyActivate(cancelRename)}>
<div class="modal">
@@ -0,0 +1,616 @@
<script>
import { createEventDispatcher } from 'svelte'
import { t } from '../../i18n'
import { isMarkdownFile } from '../../fileUtils.js'
import FileTreeRow from '../../../FileTreeRow.svelte'
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
import FilePreviewModal from '../../FilePreviewModal.svelte'
const dispatch = createEventDispatcher()
// Props
export let selectedNode = null
// Internal state
let loadingFiles = false
let currentFolderId = null
let folderStack = []
let fileItems = []
let previewItem = null
let previewContent = ''
let previewLoading = false
let previewError = ''
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
let importing = false
let importSummary = null
let showImportDialog = false
let pendingImportPath = ''
let pendingImportParent = ''
// ===== Wails call helper =====
function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const fn = window['go']['main']['App'][method]
if (typeof fn === 'function') {
return fn(...args)
}
}
} catch (e) {
console.error('Wails call error:', method, e)
}
return Promise.reject(new Error('Wails not connected: ' + method))
}
// ===== Public API for keyboard handler =====
export function filesHandleKeydown(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete') {
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
// ===== Folder navigation =====
export async function loadFolder(folderId) {
loadingFiles = true
try {
let items = await wailsCall('ListItems', folderId) || []
items.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return (a.name || '').localeCompare(b.name || '')
})
fileItems = items
} catch (e) {
fileItems = []
}
loadingFiles = false
}
async function navigateToFolder(folderId) {
if (!selectedNode) return
try {
const node = await wailsCall('GetNodeDetail', folderId)
if (node) {
folderStack = [...folderStack, { id: folderId, name: node.title }]
}
} catch (e) {
folderStack = [...folderStack, { id: folderId, name: '...' }]
}
currentFolderId = folderId
await loadFolder(folderId)
}
function navigateBack() {
if (folderStack.length < 2) {
folderStack = []
currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id)
} else {
const target = folderStack[folderStack.length - 2]
folderStack = folderStack.slice(0, -1)
currentFolderId = target.id
loadFolder(target.id)
}
}
function navigateToBreadcrumb(index) {
const target = folderStack[index]
folderStack = folderStack.slice(0, index + 1)
currentFolderId = target.id
loadFolder(target.id)
}
// ===== File preview =====
async function openPreview(item) {
if (item && item.fileId && isMarkdownFile(item)) {
try {
const action = await wailsCall('CheckFileAction', item.fileId)
if (action.action === 'note') {
dispatch('openNote', { note: { id: action.noteId, title: action.noteTitle } })
return
}
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return
}
} catch (e) {
console.warn('CheckFileAction failed, falling back to preview:', e)
}
}
previewItem = item
previewContent = ''
previewError = ''
previewLoading = true
try {
if (needsBase64Preview(item)) {
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
}
} catch (e) {
previewError = String(e)
}
previewLoading = false
}
function closePreview() {
previewItem = null
previewContent = ''
previewError = ''
}
// ===== Helpers for preview =====
function needsBase64Preview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.png') || ext.endsWith('.jpg') || ext.endsWith('.jpeg') ||
ext.endsWith('.gif') || ext.endsWith('.webp') || ext.endsWith('.svg') ||
ext.endsWith('.bmp') || ext.endsWith('.ico') || ext.endsWith('.pdf')
}
function needsTextPreview(item) {
if (!item) return false
const ext = (item.name || '').toLowerCase()
return ext.endsWith('.txt') || ext.endsWith('.log') || ext.endsWith('.json') ||
ext.endsWith('.xml') || ext.endsWith('.csv') || ext.endsWith('.md') ||
ext.endsWith('.js') || ext.endsWith('.ts') || ext.endsWith('.css') ||
ext.endsWith('.html') || ext.endsWith('.yaml') || ext.endsWith('.yml')
}
// ===== File operations =====
async function createFile() {
const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return
try {
const parentId = currentFolderId || selectedNode.id
await wailsCall('CreateEmptyFile', parentId, name.trim())
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
async function duplicateItem(id) {
try {
await wailsCall('DuplicateNode', id)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
function renameItem(id) {
const item = fileItems.find(x => x.id === id)
if (item) {
dispatch('rename', { id, name: item.name })
}
}
function cutItem(id) {
clipboard = { items: [id], mode: 'cut' }
}
function copyItem(id) {
clipboard = { items: [id], mode: 'copy' }
}
async function pasteItem() {
if (clipboard.items.length === 0) return
const targetId = currentFolderId || selectedNode.id
try {
if (clipboard.mode === 'copy') {
for (const id of clipboard.items) {
await wailsCall('DuplicateNode', id)
}
} else {
for (const id of clipboard.items) {
await wailsCall('MoveNode', id, targetId)
}
}
clipboard = { items: [], mode: 'copy' }
await loadFolder(targetId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
// ===== Selection =====
function getTargetIds(ids) {
return ids.length > 0 ? ids : fileItems.map(x => x.id)
}
function toggleSelection(id) {
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(x => x !== id)
} else {
selectedIds = [...selectedIds, id]
}
}
function selectOne(id) {
selectedIds = [id]
}
function selectAll() {
selectedIds = fileItems.map(x => x.id)
}
function clearSelection() {
selectedIds = []
}
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function cutSelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'cut' }
selectedIds = []
}
function copySelected() {
const ids = getTargetIds(selectedIds)
clipboard = { items: ids, mode: 'copy' }
selectedIds = []
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
dispatch('rename', { id: item.id, name: item.name })
}
}
}
async function deleteSelected() {
const ids = getTargetIds(selectedIds)
const item = fileItems.find(x => x.id === ids[0])
let label
if (ids.length === 1 && item?.type === 'folder') {
label = t('delete.folder')
} else if (ids.length === 1) {
label = t('delete.file')
} else {
label = t('delete.files', { count: ids.length })
}
dispatch('confirm', {
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
for (const id of ids) {
try {
await wailsCall('DeleteFileOrFolder', id)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
selectedIds = []
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
}
})
}
// ===== Drag-and-drop =====
function onDragStart(e, id) {
e.stopPropagation()
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', ids.join(','))
}
function onDragOver(e, folderId) {
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
e.stopPropagation()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
await wailsCall('MoveNode', id, folderId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
dragIds = []
selectedIds = []
await loadFolder(currentFolderId || selectedNode.id)
}
// ===== Import =====
async function addFile() {
const path = await wailsCall('PickFile')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function addFolder() {
const path = await wailsCall('PickDirectory')
if (!path) return
const parentId = currentFolderId || selectedNode.id
await startImport(parentId, path)
}
async function startImport(parentID, sourcePath) {
importing = true
try {
const summary = await wailsCall('PreviewImport', sourcePath)
importSummary = summary
pendingImportPath = sourcePath
pendingImportParent = parentID
showImportDialog = true
} catch (e) {
dispatch('error', { message: String(e) })
}
importing = false
}
async function confirmImport(mode) {
try {
const parentId = pendingImportParent || selectedNode.id
const result = mode === 'copy'
? await wailsCall('AddPathCopy', parentId, pendingImportPath)
: await wailsCall('AddPathLink', parentId, pendingImportPath)
showImportDialog = false
importSummary = null
folderStack = []
currentFolderId = null
await loadFolder(parentId)
dispatch('refreshParent', { nodeId: parentId })
} catch (e) {
dispatch('error', { message: String(e) })
}
}
function cancelImport() {
showImportDialog = false
importSummary = null
}
async function deleteFile({ id, type }) {
const label = type === 'folder' ? t('delete.folder') : t('delete.file')
dispatch('confirm', {
title: t('delete.confirmTitle'),
message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteFileOrFolder', id)
const reloadId = currentFolderId || selectedNode.id
await loadFolder(reloadId)
} catch (e) {
dispatch('error', { message: String(e) })
}
}
})
}
// ===== Public API: called from parent when node changes =====
export function resetToNode(nodeId) {
folderStack = []
currentFolderId = null
selectedIds = []
dragIds = []
clipboard = { items: [], mode: 'copy' }
fileItems = []
loadingFiles = false
previewItem = null
previewContent = ''
previewError = ''
importing = false
showImportDialog = false
importSummary = null
if (nodeId) loadFolder(nodeId)
}
// Load folder when selectedNode changes
$: if (selectedNode && selectedNode.id && !currentFolderId && fileItems.length === 0 && !loadingFiles) {
loadFolder(selectedNode.id)
}
</script>
<div class="files-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if}
</div>
{#if loadingFiles}
<div class="empty-state">
<p>{t('common.loading')}</p>
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
if (selectedNode) loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('common.backLabel')}
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if}
{#if fileItems.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
</div>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if}
<!-- Import dialog -->
{#if showImportDialog && importSummary}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('file.importTitle')} on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
<div class="modal-box" role="document">
<h3>{t('file.importTitle')}</h3>
<div class="import-summary">
<p><strong>{t('file.path')}:</strong> {importSummary.path || pendingImportPath}</p>
{#if importSummary.files}
<p>{t('file.files')}: {importSummary.files.length}</p>
{/if}
</div>
<div class="form-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
<button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
<button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<!-- File preview modal -->
{#if previewItem}
<FilePreviewModal
item={previewItem}
content={previewContent}
loading={previewLoading}
error={previewError}
{wailsCall}
on:close={closePreview}
/>
{/if}
</div>
<style>
.files-tab { padding: 20px; }
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
.file-list { display: flex; flex-direction: column; }
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
.empty-icon { margin-bottom: 12px; color: #333; }
.empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; }
.modal-box { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; min-width: 320px; max-width: 560px; width: 90vw; }
.modal-box h3 { margin: 0 0 16px; font-size: 16px; }
.import-summary { margin-bottom: 16px; font-size: 13px; }
.import-summary p { margin: 4px 0; }
.form-actions { display: flex; gap: 8px; justify-content: flex-end; }
</style>