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:
parent
208cd970d7
commit
82f59ab8da
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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue