1036 lines
32 KiB
Svelte
1036 lines
32 KiB
Svelte
<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, isTextFile, isMarkdownFile } from '../../fileUtils.js'
|
|
import { t } from '../../i18n'
|
|
import MarkdownEditor from '../notes/MarkdownEditor.svelte'
|
|
import MarkdownPreview from '../notes/MarkdownPreview.svelte'
|
|
|
|
// ===== Props =====
|
|
export let selectedNode = null
|
|
export let wailsCall = async () => {}
|
|
|
|
// ===== Debug helper =====
|
|
function _fdl(msg) {
|
|
try { wailsCall('WriteDebugLog', '[FilesTab] ' + msg) } catch(e) {}
|
|
}
|
|
|
|
// ===== Events =====
|
|
const dispatch = createEventDispatcher()
|
|
|
|
// ===== Internal state (owned by FilesTab) =====
|
|
let loadingFiles = false
|
|
let currentFolderId = null
|
|
let folderStack = []
|
|
let fileItems = []
|
|
// Preview modal state (images, PDF, binary)
|
|
let previewItem = null
|
|
let previewContent = ''
|
|
let previewLoading = false
|
|
let previewError = ''
|
|
// Text file editor state (text, markdown, code)
|
|
let textEditorFile = null
|
|
let textEditorContent = ''
|
|
let textEditorLoading = false
|
|
let textEditorError = ''
|
|
let textEditorIsMarkdown = false
|
|
let textEditorMode = 'view' // 'view' | 'edit' | 'split'
|
|
let textEditorDirty = false
|
|
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 = ''
|
|
closeTextEditor()
|
|
loadFolder(nodeId)
|
|
}
|
|
|
|
// ===== React to selectedNode changes =====
|
|
let lastLoadedNodeId = null
|
|
$: if (selectedNode && selectedNode.id && selectedNode.id !== lastLoadedNodeId) {
|
|
_fdl('selectedNode changed: id=' + selectedNode.id + ' title=' + selectedNode.title + ' type=' + selectedNode.type)
|
|
lastLoadedNodeId = selectedNode.id
|
|
resetToNode(selectedNode.id)
|
|
}
|
|
|
|
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) {
|
|
_openFile(fileItem)
|
|
}
|
|
}
|
|
|
|
export function focusItem(nodeId) {
|
|
selectedIds = [nodeId]
|
|
}
|
|
|
|
// ===== Folder navigation =====
|
|
async function _loadFolder(folderId) {
|
|
loadingFiles = true
|
|
try {
|
|
let items = await wailsCall('ListItems', folderId) || []
|
|
_fdl('loadFolder nodeId=' + folderId + ' count=' + items.length + ' first=' + (items[0] ? JSON.stringify({id: items[0].id, name: items[0].name, type: items[0].type, fileId: items[0].fileId}) : 'none'))
|
|
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) {
|
|
_fdl('loadFolder ERROR nodeId=' + folderId + ' err=' + String(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)
|
|
}
|
|
|
|
// ===== Open file: text editor or preview modal =====
|
|
async function _openFile(item) {
|
|
_fdl('openFile item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
|
|
|
|
// 1. Markdown: check if linked to a note
|
|
if (item && item.fileId && isMarkdownFile(item)) {
|
|
try {
|
|
const action = await wailsCall('CheckFileAction', item.fileId)
|
|
_fdl('CheckFileAction result=' + JSON.stringify(action))
|
|
if (action && action.action === 'note') {
|
|
// Linked note → open note editor
|
|
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
|
|
return
|
|
}
|
|
// action === 'preview' → fall through to text editor below
|
|
} catch (e) {
|
|
_fdl('CheckFileAction ERROR: ' + String(e))
|
|
}
|
|
}
|
|
|
|
// 2. Text/markdown files → TextFileEditor
|
|
if (item && item.fileId && (isTextFile(item) || isMarkdownFile(item))) {
|
|
_fdl('opening text editor for ' + item.name)
|
|
openTextEditor(item, isMarkdownFile(item))
|
|
return
|
|
}
|
|
|
|
// 3. Images / PDF / binary preview → FilePreviewModal
|
|
if (item && item.fileId && canPreviewFile(item)) {
|
|
_fdl('opening preview modal for ' + item.name)
|
|
openPreviewModal(item)
|
|
return
|
|
}
|
|
|
|
// 4. Fallback: unknown type, try as text
|
|
if (item && item.fileId && item.type !== 'folder') {
|
|
_fdl('fallback: trying text editor for unknown type ' + item.name)
|
|
openTextEditor(item, false)
|
|
return
|
|
}
|
|
|
|
_fdl('no suitable opener for ' + item.name)
|
|
}
|
|
|
|
async function openTextEditor(item, isMarkdown) {
|
|
textEditorFile = item
|
|
textEditorContent = ''
|
|
textEditorError = ''
|
|
textEditorLoading = true
|
|
textEditorIsMarkdown = isMarkdown
|
|
try {
|
|
textEditorContent = await wailsCall('ReadFileText', item.fileId) || ''
|
|
} catch (e) {
|
|
textEditorError = String(e)
|
|
}
|
|
textEditorLoading = false
|
|
}
|
|
|
|
function closeTextEditor() {
|
|
textEditorFile = null
|
|
textEditorContent = ''
|
|
textEditorError = ''
|
|
textEditorIsMarkdown = false
|
|
textEditorMode = 'view'
|
|
textEditorDirty = false
|
|
}
|
|
|
|
function saveTextEditor() {
|
|
if (!textEditorFile || !textEditorDirty) return
|
|
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
|
|
textEditorDirty = false
|
|
}
|
|
|
|
async function openPreviewModal(item) {
|
|
previewItem = item
|
|
previewContent = ''
|
|
previewError = ''
|
|
previewLoading = true
|
|
try {
|
|
if (needsBase64Preview(item)) {
|
|
previewContent = await wailsCall('GetFileBase64', 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 {
|
|
_openFile(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 (textEditorFile) { e.preventDefault(); closeTextEditor(); return }
|
|
if (previewItem) { e.preventDefault(); closePreview(); return }
|
|
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
|
} else if (e.key === 'Escape') {
|
|
if (textEditorFile) { closeTextEditor(); return }
|
|
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">
|
|
{#if textEditorFile}
|
|
<!-- ===== Editor mode ===== -->
|
|
<div class="file-editor-mode">
|
|
<div class="fe-toolbar">
|
|
<button class="btn btn-sm back-btn" on:click={closeTextEditor}>
|
|
<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('file.backToFiles')}
|
|
</button>
|
|
<span class="fe-filename" title={textEditorFile.name}>{textEditorFile.name}</span>
|
|
{#if textEditorIsMarkdown}
|
|
<span class="fe-badge">markdown</span>
|
|
{/if}
|
|
<div class="fe-spacer"></div>
|
|
{#if textEditorIsMarkdown}
|
|
<div class="fe-mode-switcher" role="tablist" aria-label="View mode">
|
|
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'view'} on:click={() => textEditorMode = 'view'}>{t('note.mode.preview')}</button>
|
|
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'edit'} on:click={() => textEditorMode = 'edit'}>{t('note.mode.edit')}</button>
|
|
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'split'} on:click={() => textEditorMode = 'split'}>{t('note.mode.split')}</button>
|
|
</div>
|
|
{/if}
|
|
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button>
|
|
</div>
|
|
<div class="fe-body">
|
|
{#if textEditorLoading}
|
|
<div class="fe-status"><p>{t('common.loading')}</p></div>
|
|
{:else if textEditorError}
|
|
<div class="fe-status fe-error">
|
|
<p>{textEditorError}</p>
|
|
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button>
|
|
</div>
|
|
{:else if textEditorIsMarkdown && textEditorMode === 'view'}
|
|
<div class="fe-markdown-preview">
|
|
<svelte:component this={MarkdownPreview} content={textEditorContent} />
|
|
</div>
|
|
{:else if textEditorIsMarkdown && textEditorMode === 'edit'}
|
|
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="edit" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
|
|
{:else if textEditorIsMarkdown && textEditorMode === 'split'}
|
|
<div class="fe-split">
|
|
<div class="fe-split-pane fe-split-editor">
|
|
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="split" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
|
|
</div>
|
|
<div class="fe-split-pane fe-split-preview">
|
|
<svelte:component this={MarkdownPreview} content={textEditorContent} />
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<pre class="fe-plain-text"><code>{textEditorContent}</code></pre>
|
|
{/if}
|
|
</div>
|
|
{#if textEditorDirty}
|
|
<div class="fe-footer">
|
|
<button class="btn btn-primary btn-sm" on:click={saveTextEditor}>{t('common.save')}</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<!-- ===== Browser mode ===== -->
|
|
<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) => _openFile(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}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Rename modal -->
|
|
{#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 -->
|
|
{#if showConfirm}
|
|
<ConfirmModal
|
|
title={confirmTitle}
|
|
message={confirmMessage}
|
|
confirmText={confirmText}
|
|
danger={confirmDanger}
|
|
on:confirm={handleConfirm}
|
|
on:cancel={handleCancel}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Import dialog -->
|
|
{#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 (images, PDF, binary only) -->
|
|
{#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; }
|
|
|
|
/* Button styles (mirroring global App.svelte .btn) */
|
|
.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-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
|
|
.btn-primary:hover { background: #4f46e5; }
|
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
|
|
.btn-danger:hover { background: #3a2222; }
|
|
|
|
/* ===== File editor mode ===== */
|
|
.file-editor-mode {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.fe-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid #2a2a3c;
|
|
background: #16161f;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.fe-filename {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: #e4e4ef;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 300px;
|
|
}
|
|
|
|
.fe-badge {
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
background: #2a2a3c;
|
|
color: #888;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.fe-spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
.fe-mode-switcher {
|
|
display: flex;
|
|
gap: 2px;
|
|
}
|
|
|
|
.fe-mode-btn {
|
|
padding: 3px 10px;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: #888;
|
|
font-size: 11px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.fe-mode-btn:hover {
|
|
color: #ccc;
|
|
background: #1e1e30;
|
|
}
|
|
|
|
.fe-mode-btn.active {
|
|
color: #e4e4ef;
|
|
background: #22223a;
|
|
border-color: #333350;
|
|
}
|
|
|
|
.fe-body {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.fe-status {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
padding: 48px 24px;
|
|
color: #888;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.fe-error {
|
|
color: #ff8888;
|
|
}
|
|
|
|
.fe-markdown-preview {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.fe-split {
|
|
flex: 1;
|
|
display: flex;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fe-split-pane {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fe-split-editor {
|
|
border-right: 1px solid #2a2a3c;
|
|
}
|
|
|
|
.fe-split-preview {
|
|
background: #11111c;
|
|
overflow-y: auto;
|
|
padding: 16px 20px;
|
|
}
|
|
|
|
.fe-plain-text {
|
|
flex: 1;
|
|
margin: 0;
|
|
padding: 16px 20px;
|
|
overflow: auto;
|
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: #ccc;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
background: #13131f;
|
|
}
|
|
|
|
.fe-plain-text code {
|
|
font-family: inherit;
|
|
background: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.fe-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid #2a2a3c;
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|