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:
@@ -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>
|
||||
Reference in New Issue
Block a user