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:
parent
490a3dd624
commit
9878ab4fb3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
<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)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if importing && !showImportDialog}
|
||||
<div class="empty-state"><p>{t('file.scanning')}</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{: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>
|
||||
Loading…
Reference in New Issue