fix: inline file editor mode in FilesTab, remove separate TextFileEditor

- Replace separate TextFileEditor modal with inline editor mode in FilesTab
- Editor mode replaces browser mode (file list) within the same .files-tab container
- Markdown files: view/edit/split modes using existing MarkdownEditor + MarkdownPreview
- Plain text files: readonly monospace viewer
- Linked .md notes → note editor (via CheckFileAction)
- Unlinked .md / text files → inline editor (not FilePreviewModal)
- FilePreviewModal only for images, PDF, binary
- Back button returns to file browser without losing folder state
- Reset editor state on selectedNode change
- Remove TextFileEditor.svelte (no longer needed)

Co-Authored-By: OWL (Hermes Agent) <hermes@nousresearch.com>
)
This commit is contained in:
2026-06-16 09:51:25 +08:00
parent d7d806530b
commit 383a9546df
6 changed files with 386 additions and 488 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DLhykySS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CPk9naW6.css">
<script type="module" crossorigin src="/assets/main-D23Sgy9Q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CoirOqGf.css">
</head>
<body>
<div id="app"></div>
+296 -92
View File
@@ -3,10 +3,11 @@
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, isTextFile, isMarkdownFile } from '../../fileUtils.js'
import { t } from '../../i18n'
import MarkdownEditor from '../notes/MarkdownEditor.svelte'
import MarkdownPreview from '../notes/MarkdownPreview.svelte'
// ===== Props =====
export let selectedNode = null
@@ -36,6 +37,8 @@
let textEditorLoading = false
let textEditorError = ''
let textEditorIsMarkdown = false
let textEditorMode = 'view' // 'view' | 'edit' | 'split'
let textEditorDirty = false
let clipboard = { items: [], mode: 'copy' }
let selectedIds = []
let dragIds = []
@@ -64,6 +67,7 @@
dragIds = []
previewItem = null
previewContent = ''
closeTextEditor()
loadFolder(nodeId)
}
@@ -210,6 +214,14 @@
textEditorContent = ''
textEditorError = ''
textEditorIsMarkdown = false
textEditorMode = 'view'
textEditorDirty = false
}
function saveTextEditor() {
if (!textEditorFile || !textEditorDirty) return
wailsCall('SaveFileText', textEditorFile.fileId, textEditorContent)
textEditorDirty = false
}
async function openPreviewModal(item) {
@@ -639,87 +651,145 @@
<!-- Files tab template -->
<div class="files-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={_addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={_addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if}
</div>
{#if loadingFiles}
<div class="empty-state">
<p>{t('common.loading')}</p>
{#if textEditorFile}
<!-- ===== Editor mode ===== -->
<div class="file-editor-mode">
<div class="fe-toolbar">
<button class="btn btn-sm back-btn" on:click={closeTextEditor}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('file.backToFiles')}
</button>
<span class="fe-filename" title={textEditorFile.name}>{textEditorFile.name}</span>
{#if textEditorIsMarkdown}
<span class="fe-badge">markdown</span>
{/if}
<div class="fe-spacer"></div>
{#if textEditorIsMarkdown}
<div class="fe-mode-switcher" role="tablist" aria-label="View mode">
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'view'} on:click={() => textEditorMode = 'view'}>{t('note.mode.preview')}</button>
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'edit'} on:click={() => textEditorMode = 'edit'}>{t('note.mode.edit')}</button>
<button type="button" class="fe-mode-btn" class:active={textEditorMode === 'split'} on:click={() => textEditorMode = 'split'}>{t('note.mode.split')}</button>
</div>
{/if}
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button>
</div>
<div class="fe-body">
{#if textEditorLoading}
<div class="fe-status"><p>{t('common.loading')}</p></div>
{:else if textEditorError}
<div class="fe-status fe-error">
<p>{textEditorError}</p>
<button class="btn btn-sm" on:click={() => wailsCall('OpenFile', textEditorFile.fileId)}>{t('file.openExternal')}</button>
</div>
{:else if textEditorIsMarkdown && textEditorMode === 'view'}
<div class="fe-markdown-preview">
<svelte:component this={MarkdownPreview} content={textEditorContent} />
</div>
{:else if textEditorIsMarkdown && textEditorMode === 'edit'}
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="edit" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
{:else if textEditorIsMarkdown && textEditorMode === 'split'}
<div class="fe-split">
<div class="fe-split-pane fe-split-editor">
<svelte:component this={MarkdownEditor} content={textEditorContent} viewMode="split" on:content-change={(e) => { textEditorContent = e.detail.content; textEditorDirty = true }} on:save={() => saveTextEditor()} />
</div>
<div class="fe-split-pane fe-split-preview">
<svelte:component this={MarkdownPreview} content={textEditorContent} />
</div>
</div>
{:else}
<pre class="fe-plain-text"><code>{textEditorContent}</code></pre>
{/if}
</div>
{#if textEditorDirty}
<div class="fe-footer">
<button class="btn btn-primary btn-sm" on:click={saveTextEditor}>{t('common.save')}</button>
</div>
{/if}
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
if (selectedNode) _loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('common.backLabel')}
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if}
<!-- ===== Browser mode ===== -->
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={_addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={_addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if}
</div>
{#if fileItems.length === 0}
{#if loadingFiles}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={_addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={_addFolder}>{t('file.addFolderSimple')}</button>
</div>
<p>{t('common.loading')}</p>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(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)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
currentFolderId = null
if (selectedNode) _loadFolder(selectedNode.id)
} else {
navigateToBreadcrumb(i - 1)
}
}}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
{t('common.backLabel')}
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div>
{#if fileItems.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">{t('file.hint')}</p>
<div class="empty-actions">
<button class="btn btn-primary" on:click={_addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={_addFolder}>{t('file.addFolderSimple')}</button>
</div>
</div>
{:else}
<div class="file-list">
{#each fileItems as item (item.id)}
<FileTreeRow
{item}
selected={selectedIds.includes(item.id)}
{onDragStart}
{onDragOver}
{onDrop}
on:navigate={(e) => navigateToFolder(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)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
on:cut={(e) => cutItem(e.detail)}
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
{/if}
{/if}
{#if importing && !showImportDialog}
<div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if}
{/if}
</div>
<!-- Rename modal (inline in FilesTab) -->
<!-- Rename modal -->
{#if showRename}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelRename} on:keydown={(e) => e.key === 'Escape' && cancelRename()}>
<div class="modal">
@@ -741,7 +811,7 @@
</div>
{/if}
<!-- Confirm modal (inline in FilesTab) -->
<!-- Confirm modal -->
{#if showConfirm}
<ConfirmModal
title={confirmTitle}
@@ -753,7 +823,7 @@
/>
{/if}
<!-- Import dialog (inline in FilesTab) -->
<!-- Import dialog -->
{#if showImportDialog && importSummary}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelImport} on:keydown={(e) => e.key === 'Escape' && cancelImport()}>
<div class="modal">
@@ -778,24 +848,7 @@
</div>
{/if}
<!-- 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) -->
<!-- File preview modal (images, PDF, binary only) -->
{#if previewItem}
<FilePreviewModal
item={previewItem}
@@ -828,4 +881,155 @@
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
/* ===== File editor mode ===== */
.file-editor-mode {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fe-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
}
.fe-filename {
font-size: 13px;
font-weight: 500;
color: #e4e4ef;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.fe-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: #2a2a3c;
color: #888;
flex-shrink: 0;
}
.fe-spacer {
flex: 1;
}
.fe-mode-switcher {
display: flex;
gap: 2px;
}
.fe-mode-btn {
padding: 3px 10px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.fe-mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.fe-mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
}
.fe-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fe-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 13px;
}
.fe-error {
color: #ff8888;
}
.fe-markdown-preview {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.fe-split {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.fe-split-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.fe-split-editor {
border-right: 1px solid #2a2a3c;
}
.fe-split-preview {
background: #11111c;
overflow-y: auto;
padding: 16px 20px;
}
.fe-plain-text {
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;
}
.fe-plain-text code {
font-family: inherit;
background: none;
padding: 0;
}
.fe-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid #2a2a3c;
flex-shrink: 0;
}
</style>
@@ -1,306 +0,0 @@
<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>