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:
2026-06-16 09:08:13 +08:00
co-authored by OWL
parent 208cd970d7
commit 82f59ab8da
12 changed files with 238 additions and 128 deletions
+9 -1
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
}
+27 -9
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>
@@ -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>
@@ -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>
@@ -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>
+2 -2
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']