fix: FilesTab empty state, button styles, text preview, menu positioning

- Add reactive $: block in FilesTab to auto-load files on selectedNode change
- Copy .btn/.btn-primary/.btn-sm styles into NotesTab.svelte and FilesTab.svelte (scoped CSS)
- Fix FilePreviewModal: render .md with MarkdownPreview, text files in <pre><code>, external open in footer
- Expand codeNames/textMimes in fileUtils.js to cover all text file types
- Add fallback text read for unknown file types in _openPreview
- Fix toggleMenu in FileTreeRow: position menu near button using getBoundingClientRect
- Add debug logs for file open flow and menu positioning
- Add tabindex+keydown to MarkdownPreview div (a11y partial fix)

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
This commit is contained in:
mirivlad 2026-06-16 09:08:13 +08:00
parent 208cd970d7
commit 82f59ab8da
12 changed files with 238 additions and 128 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-C0_3Dz8C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-t8j1WzN6.css">
<script type="module" crossorigin src="/assets/main-WcQPBFFT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DKrNFx34.css">
</head>
<body>
<div id="app"></div>

View File

@ -4,7 +4,7 @@
- **Framework**: Svelte 4 (runes-free, compiler-only)
- **Build tool**: Vite 5
- **GUI shell**: Wails v2 (Go backend + WebKit frontend)
- **GUI shell**: Wails v2 (Go backend + WebKit GTK frontend)
- **Language**: Plain JavaScript (no TypeScript in Svelte files — `lang="ts"` is NOT used)
## Source Structure
@ -37,7 +37,11 @@ frontend/src/
markdown/ # Markdown rendering + internal links
util/ # Keyboard layout helper
components/
OverviewTab.svelte # Overview tab: meta, quick actions, recent items
files/
FilesTab.svelte # Files tab: file browser, preview, import, rename
notes/
NotesTab.svelte # Notes tab: note list, create form
MarkdownEditor.svelte # Markdown textarea with toolbar
MarkdownPreview.svelte # Rendered markdown preview
NoteEditorPanel.svelte # Editor + preview layout, public API: insertText()
@ -51,13 +55,59 @@ frontend/src/
1. **Global UI state**: sidebar (system views, workspace tree), active tab, selected node/section
2. **Lifecycle**: startup checks (GetStartupStatus, VerstakVersion, ListWorkspaceTree), event listeners
3. **Top-level modals**: confirm, rename, import, worklog, inbox assign, link edit, settings, file preview
3. **Top-level modals**: confirm, node rename, import (removed — now in FilesTab), worklog, inbox assign, link edit, settings, trash preview
4. **Cross-cutting concerns**: navigation history (goBack, rememberNavigation), keyboard shortcuts, drag-and-drop orchestration, capture/inbox flow
5. **Note editor lifecycle**: `noteEditor` state, `doOpenNote()`, `saveCurrentNote()`, `closeNoteEditor()`, link modal, internal link picker
App.svelte is **NOT** responsible for:
- Individual tab content (Overview, Notes, Files) — these are inlined but should be extracted
- Note editor internals — `NoteEditorPanel.svelte` + `MarkdownEditor.svelte` handle this
- Settings sections — each has its own `Settings*.svelte`
- **Files tab**`FilesTab.svelte` (owns all file browser state, preview, import, rename)
- **Notes tab list**`NotesTab.svelte` (owns note list UI, create form; editor stays in App)
- **Overview tab**`OverviewTab.svelte` (pure display: meta, quick actions, recent items)
- **Settings sections** → each has its own `Settings*.svelte`
## Component Communication
### Props (parent → child)
Data flows down via Svelte `export let prop`
### Events (child → parent)
Children dispatch events via `createEventDispatcher()`
### Public API (bind:this)
Parent gets imperative handle via `bind:this={ref}` and calls:
- `ref.publicMethod(args)` — guard with optional chaining: `ref?.method?.(args)`
## Component Reference
### OverviewTab (`lib/components/OverviewTab.svelte`)
**Props**: `selectedNode`, `notes`, `worklog`, `formatDate`, `nodeKindLabel`
**Events**: `createNote`, `addFile`, `createAction`, `switchTab`, `openNote`
**State**: None (pure display)
### NotesTab (`lib/components/notes/NotesTab.svelte`)
**Props**: `notes`, `formatDate`
**Events**: `submitCreateNote`({title}), `openNote`({note}), `startRename`({noteId, currentTitle}), `deleteNote`({note})
**State**: `showCreateNote`, `newNoteTitle`
### FilesTab (`lib/components/files/FilesTab.svelte`)
**Props**: `selectedNode`, `wailsCall`
**Events**: `openNote`({id, title}), `refreshParent`({nodeId}), `error`({message})
**Public API** (via bind:this):
- `resetToNode(nodeId)` — reset to root of given node
- `addFile()` — open file picker and import
- `loadFolder(folderId)` — load folder contents
- `openFileById(fileNodeId)` — find and preview file
- `focusItem(nodeId)` — select item by ID
- `handleFilesKeydown(e)` — delegate keyboard handling
- `resetState()` — full reset (on node change)
**Internal state** (owned by FilesTab, NOT accessible from App):
- `loadingFiles`, `currentFolderId`, `folderStack`, `fileItems`
- `previewItem`, `previewContent`, `previewLoading`, `previewError`
- `clipboard`, `selectedIds`, `dragIds`
- `importing`, `importSummary`, `showImportDialog`, `pendingImportPath`, `pendingImportParent`
- `showRename`, `renameId`, `renameValue`, `renameError`
- `showConfirm`, `confirmTitle`, `confirmMessage`, `confirmAction`, `cancelAction`
## State Ownership Rules
@ -69,32 +119,20 @@ App.svelte is **NOT** responsible for:
- `startupStatus`, `startupChecked`
- Navigation state: `navHistory`, `restoringHistory`
- Trash browser state: `trashInfo`, `trashCount`, `trashSelectedIds`, `trashFolderId`, `trashFolderStack`
- Trash preview: `trashPreviewItem`, `trashPreviewContent`, `trashPreviewLoading`, `trashPreviewError`
- Journal state: `journalRows`, `journalSummary`, filters
- Worklog modal state: `showWorklogModal`, `wlModal*`
- Inbox: `inboxNodes`, `localInboxNodes`, capture state
- Links: `links`
- Sync: `syncStatus`
- `error` (global error banner)
- `notes` (loaded by `loadTabData`, passed to NotesTab and OverviewTab as prop)
- `worklog` (loaded by `loadTabData`, passed to OverviewTab as prop)
### App.svelte must NOT directly own (after extraction):
- Files tab internal state → `FilesTab.svelte`
- Notes tab list UI state → `NotesTab.svelte`
- Overview tab content → `OverviewTab.svelte`
## Component Communication
### Props (parent → child)
Data flows down via Svelte `export let prop`
### Events (child → parent)
Children dispatch events via `createEventDispatcher()`
- Suffix `:` in event names means handler in template: `on:openNote={handler}`, NOT call on mount
- All events should use `e.detail` to pass data
### Public API (bind:this)
Parent gets imperative handle via `bind:this={ref}` and calls:
- `ref.publicMethod(args)` — guard with optional chaining: `ref?.publicMethod?.(args)`
- Methods are exposed via `export function` in child component
### App.svelte must NOT directly reference:
- Files tab internal state (all in FilesTab)
- Notes tab create form state (showCreateNote, newNoteTitle — in NotesTab)
- Any `fileItems`, `selectedIds`, `currentFolderId`, `folderStack`, etc.
## Services (Wails API)

View File

@ -88,7 +88,14 @@
}
}
function toggleMenu() {
function toggleMenu(e) {
if (e) {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
menuX = Math.min(rect.right, window.innerWidth - 240)
menuY = Math.min(rect.bottom, window.innerHeight - 320)
console.log('[FileTreeRow] menu source=button x=' + menuX + ' y=' + menuY)
}
menuOpen = !menuOpen
}
@ -112,6 +119,7 @@
e.preventDefault()
menuX = Math.min(e.clientX, window.innerWidth - 240)
menuY = Math.min(e.clientY, window.innerHeight - 320)
console.log('[FileTreeRow] menu source=contextmenu x=' + menuX + ' y=' + menuY)
menuOpen = true
}

View File

@ -1,6 +1,7 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte'
import MarkdownPreview from './components/notes/MarkdownPreview.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
import { t } from './i18n'
@ -13,7 +14,8 @@
const kind = getFileKind(item)
$: showImage = isImageFile(item) && content && content.startsWith('data:')
$: showText = isTextFile(item) || isMarkdownFile(item)
$: showMarkdown = isMarkdownFile(item) && content
$: showText = (isTextFile(item) || isMarkdownFile(item)) && content && !showMarkdown
$: showPdf = isPdfFile(item)
function handleKeydown(e) {
@ -44,13 +46,6 @@
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
@ -71,7 +66,11 @@
<div class="preview-image-container">
<img src={content} alt={item.name} class="preview-image"/>
</div>
{:else if showText && content}
{:else if showMarkdown}
<div class="preview-markdown-container">
<MarkdownPreview {content} />
</div>
{:else if showText}
<pre class="preview-text"><code>{content}</code></pre>
{:else if showPdf}
{#if content && content.startsWith('data:')}
@ -91,6 +90,9 @@
</div>
{/if}
</div>
<footer class="preview-footer">
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</footer>
</div>
</div>
@ -260,4 +262,20 @@
.btn-sm:hover {
background: #222233;
}
.preview-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.preview-markdown-container {
flex: 1;
overflow: auto;
min-height: 0;
padding: 16px;
}
</style>

View File

@ -11,6 +11,11 @@
export let selectedNode = null
export let wailsCall = async () => {}
// ===== Debug helper =====
function _fdl(msg) {
try { wailsCall('WriteDebugLog', '[FilesTab] ' + msg) } catch(e) {}
}
// ===== Events =====
const dispatch = createEventDispatcher()
@ -54,6 +59,14 @@
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()
}
@ -79,12 +92,14 @@
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
@ -126,10 +141,13 @@
// ===== File preview =====
async function _openPreview(item) {
_fdl('openPreview item=' + JSON.stringify({id: item.id, name: item.name, type: item.type, fileId: item.fileId}))
// For .md files: check if linked to a note, open note editor instead
if (item && item.fileId && isMarkdownFile(item)) {
_fdl('isMarkdown=true fileId=' + item.fileId)
try {
const action = await wailsCall('CheckFileAction', item.fileId)
_fdl('CheckFileAction result=' + JSON.stringify(action))
if (action.action === 'note') {
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
return
@ -138,9 +156,13 @@
await wailsCall('OpenFile', item.fileId)
return
}
// action === 'preview' → fall through to built-in preview below
} catch (e) {
_fdl('CheckFileAction ERROR: ' + String(e))
console.warn('CheckFileAction failed, falling back to preview:', e)
}
} else {
_fdl('isMarkdown=false or no fileId, type=' + item.type)
}
previewItem = item
@ -152,6 +174,10 @@
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
} else if (needsTextPreview(item)) {
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
} else if (item.fileId && item.type !== 'folder') {
// Fallback: try to read as text for unknown types
_fdl('fallback text read for item=' + item.name + ' mime=' + item.mime)
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
}
} catch (e) {
previewError = String(e)
@ -731,4 +757,14 @@
.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; }
</style>

View File

@ -37,7 +37,7 @@
}
</script>
<div class="markdown-body" on:click={handleClick} role="article">
<div class="markdown-body" on:click={handleClick} role="article" tabindex="0" on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(e) }}>
{#if error}
<div class="md-error">
<p>⚠️ {t('note.preview.error')}</p>

View File

@ -99,7 +99,7 @@
.note-card-info { flex: 1; min-width: 0; }
.note-card-title { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.note-card-date { font-size: 11px; color: #555; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; }
.note-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.12s; position: absolute; top: 8px; right: 8px; }
.note-card:hover .note-card-actions { opacity: 1; }
.note-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; color: #666; cursor: pointer; transition: background 0.12s, color 0.12s; }
.note-action-btn:hover { background: #2a2a3c; color: #ccc; }
@ -107,4 +107,14 @@
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
.empty-state p { margin: 0; font-size: 14px; color: #666; }
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
/* 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; }
</style>

View File

@ -78,8 +78,8 @@ export function getFileKind(item) {
}
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript','text/csv','text/tab-separated-values','text/x-python','text/x-java','text/x-c','text/x-cpp','text/x-ruby','text/x-perl','text/x-php','text/x-go','text/x-rust','text/markdown']
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','tsv','sh','py','js','ts','css','html','md','markdown','cfg','env','gitignore','dockerignore','toml','bat','cmd','ps1','sql','graphql','proto','gradle','cmake','makefile','dockerfile','vbs','lua','r','m','scala','kt','swift','dart','elm','erl','ex','exs','fs','fsi','fsx','hs','lhs','ml','mli','pl','pm','rb','rake','rs','scm','ss','clj','cljs','cljc','edn','lisp','lsp','el','vim','vimrc','zsh','bash','fish','csh','ksh','tcsh','awk','sed','make','mk','nim','nims','d','go','java','svelte','vue','jsx','tsx','coffee','litcoffee','scss','sass','less','styl','properties','dotenv','editorconfig','gitattributes','browserslistrc','htaccess','nginx','shader','glsl','vert','frag','asm','h','hpp','cxx','cc','cpp','capnp','flatbuf','prisma','dtd','xsl','xsd','sch','svg','vtt','srt','sub','m3u','m3u8','pls','xspf','cue','toc','nfo','diz','readme','changelog','copying','license','authors','contributors','setup','config','strings','po','pot','locale','translation']
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']