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:
mirivlad 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

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>

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

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`

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">

View File

@ -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>