🐛 tab support
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
||||||
import { join, dirname } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import type { FileNode, RevisionMeta } from '../renderer/types/editor'
|
import type { FileNode, RevisionMeta } from '../renderer/types/editor'
|
||||||
|
|
||||||
const DRAFT_ROOT =
|
const DRAFT_ROOT =
|
||||||
@@ -249,3 +249,14 @@ export async function createSubdirectory(parentPath: string, name: string): Prom
|
|||||||
await mkdir(newDir, { recursive: true })
|
await mkdir(newDir, { recursive: true })
|
||||||
return newDir
|
return newDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function moveFileOrDir(sourcePath: string, targetDirPath: string): Promise<string> {
|
||||||
|
assertInDraftRoot(sourcePath)
|
||||||
|
const destDir = targetDirPath === '__root__' ? DRAFT_ROOT : targetDirPath
|
||||||
|
assertInDraftRoot(destDir)
|
||||||
|
const newPath = join(destDir, basename(sourcePath))
|
||||||
|
assertInDraftRoot(newPath)
|
||||||
|
if (newPath === sourcePath) return sourcePath
|
||||||
|
await fsRename(sourcePath, newPath)
|
||||||
|
return newPath
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { extname, basename } from 'path'
|
import { extname, basename } from 'path'
|
||||||
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory } from './fileSystem'
|
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir } from './fileSystem'
|
||||||
import { streamMessage } from './aiService'
|
import { streamMessage } from './aiService'
|
||||||
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
import type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||||
|
|
||||||
@@ -66,6 +66,10 @@ export function registerIpcHandlers(): void {
|
|||||||
return await createSubdirectory(parentPath, name)
|
return await createSubdirectory(parentPath, name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('fs:move', async (_event, sourcePath: string, targetDirPath: string) => {
|
||||||
|
return await moveFileOrDir(sourcePath, targetDirPath)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
ipcMain.handle('fs:pickAttachments', async (event): Promise<Attachment[]> => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender)
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(win!, {
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
|
|||||||
@@ -81,5 +81,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.invoke('fs:createFile', parentPath, name),
|
ipcRenderer.invoke('fs:createFile', parentPath, name),
|
||||||
|
|
||||||
createDir: (parentPath: string, name: string): Promise<string> =>
|
createDir: (parentPath: string, name: string): Promise<string> =>
|
||||||
ipcRenderer.invoke('fs:createDir', parentPath, name)
|
ipcRenderer.invoke('fs:createDir', parentPath, name),
|
||||||
|
|
||||||
|
moveFile: (sourcePath: string, targetDirPath: string): Promise<string> =>
|
||||||
|
ipcRenderer.invoke('fs:move', sourcePath, targetDirPath)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from
|
|||||||
import { EditorState, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
import { EditorState, StateField, StateEffect, Annotation, RangeSetBuilder, Compartment, Transaction } from '@codemirror/state'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||||
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll } from '@codemirror/commands'
|
import { history, defaultKeymap, historyKeymap, invertedEffects, selectAll, indentLess } from '@codemirror/commands'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { useEditorStore } from '../../store/editorStore'
|
import { useEditorStore } from '../../store/editorStore'
|
||||||
@@ -375,7 +375,18 @@ export function MarkdownEditor(): JSX.Element {
|
|||||||
doc: '',
|
doc: '',
|
||||||
extensions: [
|
extensions: [
|
||||||
history(),
|
history(),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: 'Tab',
|
||||||
|
run: (view) => {
|
||||||
|
view.dispatch(view.state.replaceSelection(' '))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ key: 'Shift-Tab', run: indentLess },
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap
|
||||||
|
]),
|
||||||
markdown(),
|
markdown(),
|
||||||
syntaxHighlighting(defaultHighlightStyle),
|
syntaxHighlighting(defaultHighlightStyle),
|
||||||
rawAnnotationsField,
|
rawAnnotationsField,
|
||||||
|
|||||||
@@ -90,7 +90,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree-dir-header.drag-over {
|
.tree-dir-header.drag-over {
|
||||||
box-shadow: inset 0 -2px 0 var(--accent);
|
background: var(--active-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Inline rename / create input ─────────────────────────────────────── */
|
/* ── Inline rename / create input ─────────────────────────────────────── */
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { MenuItem } from './ContextMenu'
|
|||||||
interface DndPayload {
|
interface DndPayload {
|
||||||
name: string
|
name: string
|
||||||
dirPath: string
|
dirPath: string
|
||||||
|
sourcePath: string
|
||||||
|
nodeType: 'file' | 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -34,8 +36,12 @@ export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Ele
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(e: React.DragEvent<HTMLElement>): void {
|
function handleDragStart(e: React.DragEvent<HTMLElement>): void {
|
||||||
|
e.stopPropagation()
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
e.dataTransfer.setData('dnd', JSON.stringify({ name: node.name, dirPath } satisfies DndPayload))
|
e.dataTransfer.setData(
|
||||||
|
'dnd',
|
||||||
|
JSON.stringify({ name: node.name, dirPath, sourcePath: node.path, nodeType: node.type } satisfies DndPayload)
|
||||||
|
)
|
||||||
e.currentTarget.classList.add('dragging')
|
e.currentTarget.classList.add('dragging')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,17 +59,45 @@ export function FileTreeNode({ node, depth, dirPath, siblings }: Props): JSX.Ele
|
|||||||
e.currentTarget.classList.remove('drag-over')
|
e.currentTarget.classList.remove('drag-over')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMoveIntoDir(sourcePath: string, targetDirPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const newPath = await window.api.moveFile(sourcePath, targetDirPath)
|
||||||
|
if (activeFilePath === sourcePath) {
|
||||||
|
const content = await window.api.readFile(newPath)
|
||||||
|
setActiveFile(newPath, content)
|
||||||
|
}
|
||||||
|
await refreshTree()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Move failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDrop(e: React.DragEvent<HTMLElement>): void {
|
function handleDrop(e: React.DragEvent<HTMLElement>): void {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
e.currentTarget.classList.remove('drag-over')
|
e.currentTarget.classList.remove('drag-over')
|
||||||
const raw = e.dataTransfer.getData('dnd')
|
const raw = e.dataTransfer.getData('dnd')
|
||||||
if (!raw) return
|
if (!raw) return
|
||||||
const payload: DndPayload = JSON.parse(raw)
|
const payload: DndPayload = JSON.parse(raw)
|
||||||
if (payload.dirPath !== dirPath) return
|
|
||||||
const fromIdx = siblings.findIndex((n) => n.name === payload.name)
|
if (node.type === 'directory') {
|
||||||
const toIdx = siblings.findIndex((n) => n.name === node.name)
|
// Drop ON a directory = move INTO it
|
||||||
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
if (payload.sourcePath === node.path) return
|
||||||
moveNode(dirPath, fromIdx, toIdx)
|
if (payload.dirPath === node.path) return
|
||||||
|
if (payload.nodeType === 'directory' && node.path.startsWith(payload.sourcePath + '/')) return
|
||||||
|
void handleMoveIntoDir(payload.sourcePath, node.path)
|
||||||
|
} else {
|
||||||
|
if (payload.dirPath === dirPath) {
|
||||||
|
// Same parent — reorder
|
||||||
|
const fromIdx = siblings.findIndex((n) => n.name === payload.name)
|
||||||
|
const toIdx = siblings.findIndex((n) => n.name === node.name)
|
||||||
|
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
||||||
|
moveNode(dirPath, fromIdx, toIdx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different parent — move to this file's parent directory
|
||||||
|
void handleMoveIntoDir(payload.sourcePath, dirPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/renderer/types/global.d.ts
vendored
1
src/renderer/types/global.d.ts
vendored
@@ -22,6 +22,7 @@ declare global {
|
|||||||
deleteNode: (targetPath: string) => Promise<void>
|
deleteNode: (targetPath: string) => Promise<void>
|
||||||
createFile: (parentPath: string, name: string) => Promise<string>
|
createFile: (parentPath: string, name: string) => Promise<string>
|
||||||
createDir: (parentPath: string, name: string) => Promise<string>
|
createDir: (parentPath: string, name: string) => Promise<string>
|
||||||
|
moveFile: (sourcePath: string, targetDirPath: string) => Promise<string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user