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:
parent
82f59ab8da
commit
d7d806530b
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-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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue