refactor(frontend): extract FilesTab safely
- New component: frontend/src/lib/components/files/FilesTab.svelte
- Owns all file tab state: loadingFiles, currentFolderId, folderStack,
fileItems, preview*, clipboard, selectedIds, dragIds, importing, importSummary, etc.
- Public API via bind:this: resetToNode(), addFile(), loadFolder(),
openFileById(), focusItem(), handleFilesKeydown(), resetState()
- Events: openNote, refreshParent, error
- Inline modals: rename, confirm, import dialog, file preview
- App.svelte changes:
- Removed all file-specific state variables and functions
- Preview state renamed to trashPreview* (for trash preview only)
- Files tab inline markup replaced with <FilesTab> component
- Overview 'Add file' button delegates to filesTabRef.addFile()
- openInboxArtifact, navigateToFile, openActivityTarget, openSearchResult
delegate to filesTabRef for file operations
- Node rename (tree context menu) uses separate openNodeRename/submitNodeRename
functions (file rename is now in FilesTab)
- closeTrashPreview for trash file preview modal
- Build: npm run build ✅, go test ./... ✅, build.sh gui ✅
This commit is contained in:
parent
df21340402
commit
acdbbdfa55
|
|
@ -18,6 +18,7 @@
|
|||
import { t } from './lib/i18n'
|
||||
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
|
||||
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
|
||||
import FilesTab from './lib/components/files/FilesTab.svelte'
|
||||
|
||||
// ===== Wails v2 API call helper =====
|
||||
function wailsCall(method, ...args) {
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
let notes = []
|
||||
let noteEditor = null
|
||||
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
|
||||
let filesTabRef = undefined; // bind:this ref for FilesTab
|
||||
let noteViewMode = 'edit'
|
||||
let showLinkModal = false
|
||||
let linkModalLabel = ''
|
||||
|
|
@ -158,26 +160,14 @@
|
|||
{ 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 trashPreviewItem = null
|
||||
let trashPreviewContent = ''
|
||||
let trashPreviewLoading = false
|
||||
let trashPreviewError = ''
|
||||
let dropRootValid = false
|
||||
let inboxDropValid = false
|
||||
let captureDropActive = false
|
||||
|
|
@ -302,12 +292,11 @@
|
|||
function closeTopModalForBack() {
|
||||
if (showConfirm) { closeConfirm(); return true }
|
||||
if (showSettings) { closeSettings(); return true }
|
||||
if (previewItem) { closePreview(); return true }
|
||||
if (trashPreviewItem) { closeTrashPreview(); 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 }
|
||||
|
|
@ -326,8 +315,8 @@
|
|||
}
|
||||
await selectNode(node)
|
||||
activeTab = snapshot.tab || 'overview'
|
||||
if (activeTab === 'files' && fileItems.length === 0) {
|
||||
await loadFolder(node.id)
|
||||
if (activeTab === 'files') {
|
||||
filesTabRef?.resetToNode?.(node.id)
|
||||
}
|
||||
return true
|
||||
} else if (snapshot.section) {
|
||||
|
|
@ -373,7 +362,7 @@
|
|||
if (activeTab === tabId) return
|
||||
rememberNavigation()
|
||||
activeTab = tabId
|
||||
if (tabId === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id)
|
||||
if (tabId === 'files' && selectedNode) filesTabRef?.resetToNode?.(selectedNode.id)
|
||||
}
|
||||
|
||||
// ===== Lifecycle =====
|
||||
|
|
@ -521,13 +510,6 @@
|
|||
actions = []
|
||||
worklog = []
|
||||
suggestions = []
|
||||
fileItems = []
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
resetTrashBrowser()
|
||||
noteEditor = null
|
||||
showCreateNode = false
|
||||
|
|
@ -552,292 +534,63 @@
|
|||
}
|
||||
|
||||
async function loadTree(nodeID) {
|
||||
loadingFiles = true
|
||||
try {
|
||||
treeItems = await wailsCall('ListItems', nodeID) || []
|
||||
} catch (e) {
|
||||
treeItems = []
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
// ===== Folder navigation =====
|
||||
// ===== Node rename (for tree context menu) =====
|
||||
function openNodeRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
async function loadFolder(folderId) {
|
||||
loadingFiles = true
|
||||
async function submitNodeRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = t('rename.emptyError'); return }
|
||||
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
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
fileItems = []
|
||||
renameError = t('rename.invalidError')
|
||||
return
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
async function navigateToFolder(folderId) {
|
||||
if (!selectedNode) return
|
||||
rememberNavigation()
|
||||
showRename = false
|
||||
const id = renameId
|
||||
renameId = ''
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', folderId)
|
||||
if (node) {
|
||||
folderStack = [...folderStack, { id: folderId, name: node.title }]
|
||||
await wailsCall('RenameNode', id, name)
|
||||
if (selectedNode && selectedNode.id === id) {
|
||||
selectedNode = { ...selectedNode, title: name }
|
||||
}
|
||||
} 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)
|
||||
await reloadTreePreservingExpanded()
|
||||
} 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 cancelNodeRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
function onNodeRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitNodeRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
clipboard = { items: [id], mode: 'cut' }
|
||||
// ===== Trash preview =====
|
||||
function closeTrashPreview() {
|
||||
trashPreviewItem = null
|
||||
trashPreviewContent = ''
|
||||
trashPreviewError = ''
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!target || !(target instanceof Element)) return false
|
||||
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
||||
|
|
@ -857,109 +610,14 @@
|
|||
return
|
||||
}
|
||||
|
||||
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()
|
||||
if (activeTab === 'files' && filesTabRef) {
|
||||
return filesTabRef.handleFilesKeydown(e)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = t('rename.emptyError'); return }
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = t('rename.invalidError')
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
const id = renameId
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', id, name)
|
||||
if (selectedNode && selectedNode.id === id) {
|
||||
selectedNode = { ...selectedNode, title: name }
|
||||
}
|
||||
await reloadTreePreservingExpanded()
|
||||
if (currentFolderId) {
|
||||
await loadFolder(currentFolderId)
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
// ===== Confirm modal =====
|
||||
|
||||
function openConfirm(opts) {
|
||||
confirmTitle = opts.title || t('common.confirm')
|
||||
|
|
@ -1111,7 +769,7 @@
|
|||
|
||||
// ===== Node operations from context menu =====
|
||||
function openRenameForNode(node) {
|
||||
openRename(node.id, node.title)
|
||||
openNodeRename(node.id, node.title)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
|
|
@ -1184,12 +842,8 @@
|
|||
selectedSection = ''
|
||||
selectedNode = item
|
||||
activeTab = 'files'
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
selectedIds = []
|
||||
previewItem = null
|
||||
await loadTabData(item.id)
|
||||
await loadFolder(item.id)
|
||||
filesTabRef?.resetToNode?.(item.id)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1199,7 +853,8 @@
|
|||
if (!record) throw new Error('file record not found')
|
||||
const preview = fileRecordToPreviewItem(item, record)
|
||||
if (canPreviewFile(preview)) {
|
||||
await openPreview(preview)
|
||||
activeTab = 'files'
|
||||
filesTabRef?.openFileById?.(item.id)
|
||||
} else {
|
||||
await wailsCall('OpenFile', preview.fileId)
|
||||
}
|
||||
|
|
@ -1508,14 +1163,8 @@
|
|||
await selectNode(parent)
|
||||
}
|
||||
setActiveTab('files')
|
||||
await loadFolder(parentId)
|
||||
// Find the file in the loaded fileItems and open preview
|
||||
const fileItem = fileItems.find(f => f.id === id)
|
||||
if (fileItem) {
|
||||
await openPreview(fileItem)
|
||||
} else {
|
||||
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
|
||||
}
|
||||
filesTabRef?.resetToNode?.(parentId)
|
||||
filesTabRef?.openFileById?.(id)
|
||||
} else {
|
||||
showVerstakToastMessage(t('note.internal.fileFound', { title: node.title }))
|
||||
}
|
||||
|
|
@ -1685,22 +1334,22 @@
|
|||
}
|
||||
|
||||
async function openTrashFilePreview(node) {
|
||||
previewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
previewLoading = true
|
||||
trashPreviewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
|
||||
trashPreviewContent = ''
|
||||
trashPreviewError = ''
|
||||
trashPreviewLoading = true
|
||||
try {
|
||||
if (node.trashFsPath) {
|
||||
previewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
|
||||
trashPreviewContent = await wailsCall('ReadTrashFile', node.trashFsPath) || ''
|
||||
} else {
|
||||
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
||||
trashPreviewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
|
||||
}
|
||||
const ext = (node.title || '').split('.').pop().toLowerCase()
|
||||
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
|
||||
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
||||
trashPreviewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
|
||||
}
|
||||
} catch (e) { previewError = String(e) }
|
||||
previewLoading = false
|
||||
} catch (e) { trashPreviewError = String(e) }
|
||||
trashPreviewLoading = false
|
||||
}
|
||||
|
||||
function toggleTrashSelection(id) {
|
||||
|
|
@ -1980,88 +1629,7 @@
|
|||
}
|
||||
|
||||
// ===== 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openSelectedFile(fileID) {
|
||||
try {
|
||||
await wailsCall('OpenFile', fileID)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Drag-and-drop =====
|
||||
// ===== Drag-and-drop =====
|
||||
async function onFilesDropped(paths) {
|
||||
try {
|
||||
if (!paths || paths.length === 0) return
|
||||
|
|
@ -2342,7 +1910,6 @@
|
|||
}
|
||||
}
|
||||
function hasExternalCaptureData(dataTransfer) {
|
||||
if (dragIds.length > 0) return false
|
||||
const types = Array.from(dataTransfer?.types || [])
|
||||
return types.includes('Files') ||
|
||||
types.includes('text/uri-list') ||
|
||||
|
|
@ -2669,20 +2236,17 @@
|
|||
try {
|
||||
const detail = await wailsCall('GetNodeDetail', target.targetId)
|
||||
if (detail && detail.parent_id) {
|
||||
await loadFolder(detail.parent_id)
|
||||
const fileItem = fileItems.find(f => f.id === target.targetId)
|
||||
if (fileItem && fileItem.type === 'file' && canPreviewFile(fileItem)) {
|
||||
setTimeout(() => openPreview(fileItem), 150)
|
||||
}
|
||||
filesTabRef?.resetToNode?.(detail.parent_id)
|
||||
filesTabRef?.openFileById?.(target.targetId)
|
||||
} else {
|
||||
// No parent — item sits at the root level
|
||||
await loadFolder(targetNode)
|
||||
filesTabRef?.resetToNode?.(targetNode)
|
||||
}
|
||||
} catch(e) {
|
||||
await loadFolder(targetNode)
|
||||
filesTabRef?.resetToNode?.(targetNode)
|
||||
}
|
||||
} else {
|
||||
await loadFolder(targetNode)
|
||||
filesTabRef?.resetToNode?.(targetNode)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -2733,18 +2297,15 @@
|
|||
if (parent) {
|
||||
await selectNode(parent)
|
||||
setActiveTab('files')
|
||||
await loadFolder(parent.id)
|
||||
const fileItem = fileItems.find(item => item.id === detail.id)
|
||||
if (fileItem && canPreviewFile(fileItem)) {
|
||||
await openPreview(fileItem)
|
||||
}
|
||||
filesTabRef?.resetToNode?.(parent.id)
|
||||
filesTabRef?.openFileById?.(detail.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.type === 'folder') {
|
||||
await selectNode(detail)
|
||||
setActiveTab('files')
|
||||
await loadFolder(detail.id)
|
||||
filesTabRef?.resetToNode?.(detail.id)
|
||||
return
|
||||
}
|
||||
await selectNode(detail)
|
||||
|
|
@ -2839,6 +2400,8 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
{#if showFirstRun}
|
||||
<FirstRun onComplete={onFirstRunComplete} />
|
||||
{:else if showRecovery}
|
||||
|
|
@ -3016,7 +2579,7 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
{t('overview.newNote')}
|
||||
</button>
|
||||
<button class="qa-btn" on:click={() => { setActiveTab('files'); addFile() }}>
|
||||
<button class="qa-btn" on:click={() => { setActiveTab('files'); if (filesTabRef) filesTabRef.addFile(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
{t('overview.addFile')}
|
||||
</button>
|
||||
|
|
@ -3089,87 +2652,14 @@
|
|||
</div>
|
||||
|
||||
{: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}
|
||||
{wailsCall}
|
||||
on:openNote={(e) => openNote(e.detail)}
|
||||
on:refreshParent={(e) => refreshParentNode(e.detail.nodeId)}
|
||||
on:error={(e) => error = e.detail.message}
|
||||
/>
|
||||
|
||||
{:else if activeTab === 'inbox'}
|
||||
<div class="inbox-tab">
|
||||
|
|
@ -4035,46 +3525,22 @@
|
|||
</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-overlay" role="button" tabindex="0" on:click|self={cancelNodeRename} on:keydown={onKeyActivate(cancelNodeRename)}>
|
||||
<div class="modal">
|
||||
<h3>{t('rename.title')}</h3>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('common.newName')}</span>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
on:keydown={onNodeRenameKeydown} />
|
||||
</label>
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
|
||||
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
|
||||
<button class="btn btn-primary" on:click={submitNodeRename}>{t('common.rename')}</button>
|
||||
<button class="btn" on:click={cancelNodeRename}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4155,13 +3621,13 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
{#if previewItem}
|
||||
{#if trashPreviewItem}
|
||||
<FilePreviewModal
|
||||
item={previewItem}
|
||||
content={previewContent}
|
||||
loading={previewLoading}
|
||||
error={previewError}
|
||||
on:close={closePreview}
|
||||
item={trashPreviewItem}
|
||||
content={trashPreviewContent}
|
||||
loading={trashPreviewLoading}
|
||||
error={trashPreviewError}
|
||||
on:close={closeTrashPreview}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -4173,6 +3639,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.app { display: flex; width: 100vw; height: 100vh; overflow: hidden; background: #13131f; color: #e4e4ef; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; }
|
||||
|
|
@ -4558,17 +4025,9 @@
|
|||
}
|
||||
|
||||
/* Files tab */
|
||||
.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; }
|
||||
|
||||
/* Import summary */
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* Template cards */
|
||||
.template-cards { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,734 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
|
||||
import FileTreeRow from '../../../FileTreeRow.svelte'
|
||||
import FilePreviewModal from '../FilePreviewModal.svelte'
|
||||
import ConfirmModal from '../ConfirmModal.svelte'
|
||||
import { canPreviewFile, needsBase64Preview, needsTextPreview, isMarkdownFile } from '../fileUtils.js'
|
||||
import { t } from '../i18n'
|
||||
|
||||
// ===== Props =====
|
||||
export let selectedNode = null
|
||||
export let wailsCall = async () => {}
|
||||
|
||||
// ===== Events =====
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// ===== Internal state (owned by FilesTab) =====
|
||||
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 = ''
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
let showConfirm = false
|
||||
let confirmTitle = ''
|
||||
let confirmMessage = ''
|
||||
let confirmDanger = false
|
||||
let confirmText = t('common.delete')
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
// ===== Public API (called via bind:this) =====
|
||||
export function resetToNode(nodeId) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
loadFolder(nodeId)
|
||||
}
|
||||
|
||||
export function addFile() {
|
||||
_addFile()
|
||||
}
|
||||
|
||||
export function loadFolder(folderId) {
|
||||
_loadFolder(folderId)
|
||||
}
|
||||
|
||||
export function openFileById(fileNodeId) {
|
||||
// Find the file in the current fileItems and open preview
|
||||
const fileItem = fileItems.find(f => f.id === fileNodeId)
|
||||
if (fileItem) {
|
||||
_openPreview(fileItem)
|
||||
}
|
||||
}
|
||||
|
||||
export function focusItem(nodeId) {
|
||||
selectedIds = [nodeId]
|
||||
}
|
||||
|
||||
// ===== 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
|
||||
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) {
|
||||
// For .md files: check if linked to a note, open note editor instead
|
||||
if (item && item.fileId && isMarkdownFile(item)) {
|
||||
try {
|
||||
const action = await wailsCall('CheckFileAction', item.fileId)
|
||||
if (action.action === 'note') {
|
||||
dispatch('openNote', { 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 = ''
|
||||
}
|
||||
|
||||
// ===== File operations =====
|
||||
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 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) _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) {
|
||||
dispatch('error', { message: 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) {
|
||||
dispatch('error', { message: 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) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
dragIds = []
|
||||
selectedIds = []
|
||||
await _loadFolder(currentFolderId || selectedNode.id)
|
||||
}
|
||||
|
||||
// ===== Keyboard =====
|
||||
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) {
|
||||
_openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
function _openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = t('rename.emptyError'); return }
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = t('rename.invalidError')
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
const id = renameId
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', id, name)
|
||||
if (selectedNode && selectedNode.id === id) {
|
||||
selectedNode = { ...selectedNode, title: name }
|
||||
}
|
||||
dispatch('refreshParent', { nodeId: currentFolderId || selectedNode.id })
|
||||
if (currentFolderId) {
|
||||
await _loadFolder(currentFolderId)
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
function _openConfirm(opts) {
|
||||
confirmTitle = opts.title || t('common.confirm')
|
||||
confirmMessage = opts.message || ''
|
||||
confirmDanger = opts.danger !== undefined ? opts.danger : true
|
||||
confirmText = opts.confirmText || t('common.delete')
|
||||
confirmAction = opts.onConfirm || null
|
||||
cancelAction = opts.onCancel || null
|
||||
showConfirm = true
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
showConfirm = false
|
||||
confirmAction = null
|
||||
cancelAction = null
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmAction) confirmAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (cancelAction) cancelAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
// ===== Import =====
|
||||
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
|
||||
if (mode === 'copy') {
|
||||
await wailsCall('AddPathCopy', parentId, pendingImportPath)
|
||||
} else {
|
||||
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')
|
||||
_openConfirm({
|
||||
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) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openSelectedFile(fileID) {
|
||||
try {
|
||||
await wailsCall('OpenFile', fileID)
|
||||
} catch (e) {
|
||||
dispatch('error', { message: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Keyboard handler (called from parent) =====
|
||||
export function handleFilesKeydown(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()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Reset on node change =====
|
||||
export function resetState() {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
fileItems = []
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
importing = false
|
||||
importSummary = null
|
||||
showImportDialog = false
|
||||
showRename = false
|
||||
showConfirm = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Files tab template -->
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<!-- Rename modal (inline in FilesTab) -->
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
|
||||
<div class="modal">
|
||||
<h3>{t('rename.title')}</h3>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('common.newName')}</span>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</label>
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
|
||||
<button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm modal (inline in FilesTab) -->
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
message={confirmMessage}
|
||||
confirmText={confirmText}
|
||||
danger={confirmDanger}
|
||||
on:confirm={handleConfirm}
|
||||
on:cancel={handleCancel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Import dialog (inline in FilesTab) -->
|
||||
{#if showImportDialog && importSummary}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && 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}
|
||||
|
||||
<!-- File preview modal (inline in FilesTab) -->
|
||||
{#if previewItem}
|
||||
<FilePreviewModal
|
||||
item={previewItem}
|
||||
content={previewContent}
|
||||
loading={previewLoading}
|
||||
error={previewError}
|
||||
on:close={closePreview}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<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; }
|
||||
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue