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:
mirivlad 2026-06-16 09:26:44 +08:00
parent 82f59ab8da
commit d7d806530b
6 changed files with 482 additions and 115 deletions

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-WcQPBFFT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DKrNFx34.css">
<script type="module" crossorigin src="/assets/main-DLhykySS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CPk9naW6.css">
</head>
<body>
<div id="app"></div>

View File

@ -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}

View File

@ -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>