feat: TextFileEditor for text/markdown files, fix menu positioning

- Create TextFileEditor.svelte: full editor/viewer for text files
  - Markdown: edit/preview/split modes with MarkdownEditor + MarkdownPreview
  - Plain text: readonly monospace viewer
  - Footer: external open button + save (markdown) + close
- Rewrite _openFile in FilesTab: text/markdown → TextFileEditor, images/PDF → FilePreviewModal
- Linked .md notes → note editor (via CheckFileAction)
- Unlinked .md / text files → TextFileEditor (not FilePreviewModal)
- Fix toggleMenu in FileTreeRow: position using getBoundingClientRect
- Expand codeNames/textMimes for all text file types
- Add debug logs for file open flow and menu positioning

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
This commit is contained in:
2026-06-16 09:26:44 +08:00
co-authored by OWL
parent 82f59ab8da
commit d7d806530b
6 changed files with 482 additions and 115 deletions
@@ -3,8 +3,9 @@
import FileBreadcrumbs from '../../FileBreadcrumbs.svelte'
import FileTreeRow from '../../../FileTreeRow.svelte'
import FilePreviewModal from '../../FilePreviewModal.svelte'
import TextFileEditor from './TextFileEditor.svelte'
import ConfirmModal from '../../ConfirmModal.svelte'
import { canPreviewFile, needsBase64Preview, needsTextPreview, isMarkdownFile } from '../../fileUtils.js'
import { canPreviewFile, needsBase64Preview, isTextFile, isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n'
// ===== Props =====
@@ -24,10 +25,17 @@
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 clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
@@ -79,7 +87,7 @@
// Find the file in the current fileItems and open preview
const fileItem = fileItems.find(f => f.id === fileNodeId)
if (fileItem) {
_openPreview(fileItem)
_openFile(fileItem)
}
}
@@ -139,32 +147,72 @@
_loadFolder(target.id)
}
// ===== 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
// ===== 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)) {
_fdl('isMarkdown=true fileId=' + item.fileId)
try {
const action = await wailsCall('CheckFileAction', item.fileId)
_fdl('CheckFileAction result=' + JSON.stringify(action))
if (action.action === 'note') {
if (action && action.action === 'note') {
// Linked note → open note editor
dispatch('openNote', { id: action.noteId, title: action.noteTitle })
return
}
if (action.action === 'external') {
await wailsCall('OpenFile', item.fileId)
return
}
// action === 'preview' → fall through to built-in preview below
// action === 'preview' → fall through to text editor 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)
}
// 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
}
async function openPreviewModal(item) {
previewItem = item
previewContent = ''
previewError = ''
@@ -172,12 +220,6 @@
try {
if (needsBase64Preview(item)) {
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)
@@ -387,7 +429,7 @@
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
_openPreview(item)
_openFile(item)
}
}
}
@@ -563,9 +605,11 @@
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') {
@@ -653,7 +697,7 @@
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => _openPreview(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)}
@@ -734,7 +778,24 @@
</div>
{/if}
<!-- File preview modal (inline in FilesTab) -->
<!-- Text file editor (inline in FilesTab) -->
{#if textEditorFile}
<TextFileEditor
file={textEditorFile}
content={textEditorContent}
isMarkdown={textEditorIsMarkdown}
loading={textEditorLoading}
error={textEditorError}
on:close={closeTextEditor}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:save={(e) => {
wailsCall('SaveFileText', e.detail.fileId, e.detail.content)
closeTextEditor()
}}
/>
{/if}
<!-- File preview modal (images, PDF, binary) -->
{#if previewItem}
<FilePreviewModal
item={previewItem}
@@ -0,0 +1,306 @@
<script>
import { createEventDispatcher } from 'svelte'
import MarkdownEditor from '../notes/MarkdownEditor.svelte'
import MarkdownPreview from '../notes/MarkdownPreview.svelte'
import { t } from '../../i18n'
import { formatFileSize, formatMimeType } from '../../fileUtils.js'
export let file = null
export let content = ''
export let isMarkdown = false
export let loading = false
export let error = ''
const dispatch = createEventDispatcher()
let viewMode = 'edit' // 'edit' | 'preview' | 'split'
let dirty = false
let activeEditor = undefined
function handleContentChange(e) {
content = e.detail.content
dirty = true
dispatch('content-change', e.detail)
}
function handleSave() {
if (!file) return
dispatch('save', { fileId: file.fileId, content })
dirty = false
}
function handleOpenExternal() {
if (file) dispatch('openExternal', file.fileId)
}
function handleClose() {
dispatch('close')
}
</script>
<div class="text-editor">
<header class="te-header">
<div class="te-title-row">
<span class="te-name" title={file ? file.name : ''}>{file ? file.name : ''}</span>
{#if dirty}<span class="te-dirty" title="Unsaved changes"></span>{/if}
{#if isMarkdown}
<span class="te-badge">markdown</span>
{/if}
</div>
<div class="te-meta">
{#if file}
<span>{formatFileSize(file.size)}</span>
<span class="te-sep">·</span>
<span>{formatMimeType(file.mime)}</span>
{/if}
</div>
{#if isMarkdown}
<div class="te-mode-switcher" role="tablist" aria-label="View mode">
<button type="button" class="te-mode-btn" role="tab" class:active={viewMode === 'edit'} on:click={() => viewMode = 'edit'}>
{t('note.mode.edit')}
</button>
<button type="button" class="te-mode-btn" role="tab" class:active={viewMode === 'preview'} on:click={() => viewMode = 'preview'}>
{t('note.mode.preview')}
</button>
<button type="button" class="te-mode-btn" role="tab" class:active={viewMode === 'split'} on:click={() => viewMode = 'split'}>
{t('note.mode.split')}
</button>
</div>
{/if}
</header>
<div class="te-body">
{#if loading}
<div class="te-status"><p>{t('common.loading')}</p></div>
{:else if error}
<div class="te-status te-error">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div>
{:else if isMarkdown && viewMode === 'edit'}
<MarkdownEditor
bind:this={activeEditor}
{content}
viewMode="edit"
on:content-change={handleContentChange}
on:save={handleSave}
/>
{:else if isMarkdown && viewMode === 'preview'}
<div class="te-preview-pane">
<MarkdownPreview {content} />
</div>
{:else if isMarkdown && viewMode === 'split'}
<div class="te-split-pane">
<div class="te-split-editor">
<MarkdownEditor
bind:this={activeEditor}
{content}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
/>
</div>
<div class="te-split-preview">
<MarkdownPreview {content} />
</div>
</div>
{:else}
<!-- Plain text viewer (readonly) -->
<pre class="te-text-plain"><code>{content}</code></pre>
{/if}
</div>
<footer class="te-footer">
<button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
<div class="te-footer-right">
{#if isMarkdown}
<button class="btn btn-sm btn-primary" on:click={handleSave} disabled={!dirty}>{t('common.save')}</button>
{/if}
<button class="btn btn-sm" on:click={handleClose}>{t('common.close')}</button>
</div>
</footer>
</div>
<style>
.text-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #13131f;
}
.te-header {
padding: 10px 16px 8px;
border-bottom: 1px solid #2a2a3c;
flex-shrink: 0;
}
.te-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.te-name {
font-size: 14px;
font-weight: 500;
color: #e4e4ef;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.te-dirty {
color: #f59e0b;
font-size: 8px;
flex-shrink: 0;
}
.te-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #2a2a3c;
color: #888;
flex-shrink: 0;
}
.te-meta {
font-size: 11px;
color: #555;
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.te-sep {
color: #444;
}
.te-mode-switcher {
display: flex;
gap: 2px;
}
.te-mode-btn {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.te-mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.te-mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
}
.te-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.te-preview-pane {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.te-split-pane {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.te-split-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #2a2a3c;
overflow: hidden;
}
.te-split-preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 16px 20px;
background: #11111c;
}
.te-text-plain {
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;
}
.te-text-plain code {
font-family: inherit;
background: none;
padding: 0;
}
.te-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 13px;
}
.te-error {
color: #ff8888;
}
.te-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
.te-footer-right {
display: flex;
gap: 8px;
}
.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-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
</style>