Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons
This commit is contained in:
parent
645d8878cc
commit
2487d3bbaa
|
|
@ -19,6 +19,8 @@ import (
|
|||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// App is the Wails v2 application adapter. It wraps core services.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
|
|
@ -32,9 +34,14 @@ type App struct {
|
|||
vault string
|
||||
}
|
||||
|
||||
// startup is called when the app starts. Store context.
|
||||
// startup is called when the app starts. Store context and wire drag-and-drop.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
|
||||
if len(paths) > 0 {
|
||||
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -75,6 +82,16 @@ type FileDTO struct {
|
|||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
type FileTreeItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
FileID string `json:"fileId,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
HasKids bool `json:"hasKids"`
|
||||
}
|
||||
|
||||
type ActionDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
|
|
@ -199,6 +216,7 @@ func (a *App) SaveNote(noteID, content string) error {
|
|||
// Files
|
||||
// ============================================================
|
||||
|
||||
// ListFiles returns file records directly linked to a node (non-recursive).
|
||||
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
||||
records, err := a.files.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
|
|
@ -206,22 +224,105 @@ func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
|
|||
}
|
||||
result := make([]FileDTO, len(records))
|
||||
for i := range records {
|
||||
isDir := records[i].MIME == "inode/directory"
|
||||
missing := false
|
||||
rec := &records[i]
|
||||
result[i] = FileDTO{
|
||||
ID: records[i].ID,
|
||||
NodeID: records[i].NodeID,
|
||||
Name: records[i].Filename,
|
||||
Path: records[i].Path,
|
||||
Size: records[i].Size,
|
||||
Mime: records[i].MIME,
|
||||
IsDir: isDir,
|
||||
Missing: missing,
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
Name: rec.Filename,
|
||||
Path: rec.Path,
|
||||
Size: rec.Size,
|
||||
Mime: rec.MIME,
|
||||
IsDir: rec.MIME == "inode/directory",
|
||||
Missing: rec.Missing,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListItems returns children of a node for the file tree view.
|
||||
// Folders can be expanded; files include their file record info.
|
||||
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
|
||||
children, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]FileTreeItemDTO, 0, len(children))
|
||||
for i := range children {
|
||||
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
|
||||
continue
|
||||
}
|
||||
item := FileTreeItemDTO{
|
||||
ID: children[i].ID,
|
||||
Name: children[i].Title,
|
||||
Type: children[i].Type,
|
||||
}
|
||||
if children[i].Type == nodes.TypeFolder {
|
||||
// Check if this folder has children
|
||||
kids, _ := a.nodes.ListChildren(children[i].ID, false)
|
||||
item.HasKids = len(kids) > 0
|
||||
} else if children[i].Type == nodes.TypeFile {
|
||||
records, _ := a.files.ListByNode(children[i].ID)
|
||||
if len(records) > 0 {
|
||||
item.FileID = records[0].ID
|
||||
item.Size = records[0].Size
|
||||
item.Mime = records[0].MIME
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
||||
func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||
return a.files.DeleteNodeAndChildren(nodeID)
|
||||
}
|
||||
|
||||
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||
node, err := a.files.CreateEmptyFile(parentID, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||
node, err := a.files.Duplicate(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
return a.nodes.Move(nodeID, newParentID, 0)
|
||||
}
|
||||
|
||||
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
|
||||
return a.files.PreviewImport(sourcePath)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Actions
|
||||
// ============================================================
|
||||
|
|
@ -352,6 +453,14 @@ func (a *App) OpenFile(fileID string) error {
|
|||
return a.files.Open(fileID)
|
||||
}
|
||||
|
||||
func (a *App) ReadFileText(fileID string) (string, error) {
|
||||
return a.files.ReadText(fileID)
|
||||
}
|
||||
|
||||
func (a *App) GetFileBase64(fileID string) (string, error) {
|
||||
return a.files.ReadBase64(fileID)
|
||||
}
|
||||
|
||||
func (a *App) OpenFolder(nodeID string) error {
|
||||
n, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
|
|
|
|||
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
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
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
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
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
File diff suppressed because one or more lines are too long
|
|
@ -16,8 +16,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-BqdVWy5o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-D8LYjC_e.css">
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func main() {
|
|||
|
||||
// Init core services
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, abs)
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
|
|
@ -72,6 +72,9 @@ func main() {
|
|||
Assets: assets,
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
DragAndDrop: &options.DragAndDrop{
|
||||
EnableFileDrop: true,
|
||||
},
|
||||
Bind: []interface{}{app},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import FileTreeRow from './FileTreeRow.svelte'
|
||||
import FileBreadcrumbs from './lib/FileBreadcrumbs.svelte'
|
||||
import FilePreviewModal from './lib/FilePreviewModal.svelte'
|
||||
import ConfirmModal from './lib/ConfirmModal.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||
|
||||
// ===== Wails v2 API call helper =====
|
||||
// In production: window['go']['main']['App']['MethodName'](...args)
|
||||
|
|
@ -39,6 +44,33 @@
|
|||
let showCreateNote = false
|
||||
let newNoteTitle = ''
|
||||
let loading = true
|
||||
let importing = false
|
||||
let importSummary = null
|
||||
let showImportDialog = false
|
||||
let pendingImportPath = ''
|
||||
let pendingImportMode = 'copy'
|
||||
let treeItems = []
|
||||
let expanded = {}
|
||||
let childrenMap = {}
|
||||
let loadingFiles = false
|
||||
let currentFolderId = null
|
||||
let folderStack = []
|
||||
let fileItems = []
|
||||
let previewItem = null
|
||||
let previewContent = ''
|
||||
let previewLoading = false
|
||||
let previewError = ''
|
||||
let clipboard = { items: [], mode: 'copy' }
|
||||
let selectedIds = []
|
||||
let dragIds = []
|
||||
|
||||
let showConfirm = false
|
||||
let confirmTitle = ''
|
||||
let confirmMessage = ''
|
||||
let confirmDanger = false
|
||||
let confirmText = 'Удалить'
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
|
|
@ -49,6 +81,8 @@
|
|||
{ id: 'activity', label: 'Активность' },
|
||||
]
|
||||
|
||||
let unlistenDrop = null
|
||||
|
||||
// ===== Lifecycle =====
|
||||
onMount(async () => {
|
||||
try {
|
||||
|
|
@ -67,9 +101,23 @@
|
|||
{ id: 'archive', label: 'Архив' },
|
||||
]
|
||||
}
|
||||
|
||||
// Listen for file drops from OS file manager.
|
||||
if (window.runtime && window.runtime.EventsOn) {
|
||||
window.runtime.EventsOn('files-dropped', onFilesDropped)
|
||||
unlistenDrop = () => window.runtime.EventsOff('files-dropped')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
loading = false
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlistenDrop) unlistenDrop()
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// ===== Section / Node selection =====
|
||||
async function selectSection(id) {
|
||||
selectedSection = id
|
||||
|
|
@ -96,6 +144,16 @@
|
|||
files = []
|
||||
actions = []
|
||||
worklog = []
|
||||
treeItems = []
|
||||
expanded = {}
|
||||
childrenMap = {}
|
||||
fileItems = []
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
selectedIds = []
|
||||
dragIds = []
|
||||
noteEditor = null
|
||||
showCreateNode = false
|
||||
showCreateNote = false
|
||||
|
|
@ -110,6 +168,296 @@
|
|||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||
}
|
||||
|
||||
async function loadTree(nodeID) {
|
||||
loadingFiles = true
|
||||
try {
|
||||
treeItems = await wailsCall('ListItems', nodeID) || []
|
||||
} catch (e) {
|
||||
treeItems = []
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
// ===== Folder navigation =====
|
||||
|
||||
async function loadFolder(folderId) {
|
||||
loadingFiles = true
|
||||
try {
|
||||
let items = await wailsCall('ListItems', folderId) || []
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
|
||||
return (a.name || '').localeCompare(b.name || '')
|
||||
})
|
||||
fileItems = items
|
||||
} catch (e) {
|
||||
fileItems = []
|
||||
}
|
||||
loadingFiles = false
|
||||
}
|
||||
|
||||
async function navigateToFolder(folderId) {
|
||||
if (!selectedNode) return
|
||||
// Get the folder node name for breadcrumbs
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', folderId)
|
||||
if (node) {
|
||||
folderStack = [...folderStack, { id: folderId, name: node.title }]
|
||||
}
|
||||
} catch (e) {
|
||||
folderStack = [...folderStack, { id: folderId, name: '...' }]
|
||||
}
|
||||
currentFolderId = folderId
|
||||
await loadFolder(folderId)
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
if (folderStack.length < 2) {
|
||||
// Go back to root
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
loadFolder(selectedNode.id)
|
||||
} else {
|
||||
const target = folderStack[folderStack.length - 2]
|
||||
folderStack = folderStack.slice(0, -1)
|
||||
currentFolderId = target.id
|
||||
loadFolder(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index) {
|
||||
const target = folderStack[index]
|
||||
folderStack = folderStack.slice(0, index + 1)
|
||||
currentFolderId = target.id
|
||||
loadFolder(target.id)
|
||||
}
|
||||
|
||||
// ===== File preview =====
|
||||
|
||||
async function openPreview(item) {
|
||||
previewItem = item
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
previewLoading = true
|
||||
|
||||
try {
|
||||
if (needsBase64Preview(item)) {
|
||||
previewContent = await wailsCall('GetFileBase64', item.fileId) || ''
|
||||
} else if (needsTextPreview(item)) {
|
||||
previewContent = await wailsCall('ReadFileText', item.fileId) || ''
|
||||
}
|
||||
} catch (e) {
|
||||
previewError = String(e)
|
||||
}
|
||||
previewLoading = false
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewItem = null
|
||||
previewContent = ''
|
||||
previewError = ''
|
||||
}
|
||||
|
||||
// ===== File operations =====
|
||||
|
||||
async function createFile() {
|
||||
const name = prompt('Введите имя файла:')
|
||||
if (!name || !name.trim()) return
|
||||
try {
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await wailsCall('CreateEmptyFile', parentId, name.trim())
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateItem(id) {
|
||||
try {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const name = prompt('Новое имя:')
|
||||
if (!name || !name.trim()) return
|
||||
renameSubmit(id, name.trim())
|
||||
}
|
||||
|
||||
async function renameSubmit(id, newName) {
|
||||
try {
|
||||
await wailsCall('RenameNode', id, newName)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
clipboard = { items: [id], mode: 'cut' }
|
||||
}
|
||||
|
||||
function copyItem(id) {
|
||||
clipboard = { items: [id], mode: 'copy' }
|
||||
}
|
||||
|
||||
async function pasteItem() {
|
||||
if (clipboard.items.length === 0) return
|
||||
const targetId = currentFolderId || selectedNode.id
|
||||
try {
|
||||
if (clipboard.mode === 'copy') {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('DuplicateNode', id)
|
||||
}
|
||||
} else {
|
||||
for (const id of clipboard.items) {
|
||||
await wailsCall('MoveNode', id, targetId)
|
||||
}
|
||||
}
|
||||
clipboard = { items: [], mode: 'copy' }
|
||||
await loadFolder(targetId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Selection =====
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (selectedIds.includes(id)) {
|
||||
selectedIds = selectedIds.filter(x => x !== id)
|
||||
} else {
|
||||
selectedIds = [...selectedIds, id]
|
||||
}
|
||||
}
|
||||
|
||||
function selectOne(id) {
|
||||
selectedIds = [id]
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function getTargetIds(ids) {
|
||||
return ids.length > 0 ? ids : fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
const label = ids.length === 1 && fileItems.find(x => x.id === ids[0])?.type === 'folder' ? 'папку' : `файлов (${ids.length})`
|
||||
openConfirm({
|
||||
title: 'Удаление',
|
||||
message: `Удалить ${label}?`,
|
||||
confirmText: 'Удалить',
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
selectedIds = []
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await loadFolder(reloadId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cutSelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'cut' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
const ids = getTargetIds(selectedIds)
|
||||
clipboard = { items: ids, mode: 'copy' }
|
||||
selectedIds = []
|
||||
}
|
||||
|
||||
// ===== Drag-and-drop =====
|
||||
|
||||
function onDragStart(e, id) {
|
||||
const ids = selectedIds.includes(id) ? selectedIds : [id]
|
||||
dragIds = ids
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', ids.join(','))
|
||||
}
|
||||
|
||||
function onDragOver(e, folderId) {
|
||||
const item = fileItems.find(x => x.id === folderId)
|
||||
if (item && item.type === 'folder') {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(e, folderId) {
|
||||
e.preventDefault()
|
||||
if (dragIds.length === 0) return
|
||||
for (const id of dragIds) {
|
||||
try {
|
||||
await wailsCall('MoveNode', id, folderId)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
dragIds = []
|
||||
selectedIds = []
|
||||
await loadFolder(currentFolderId || selectedNode.id)
|
||||
}
|
||||
|
||||
// ===== Keyboard =====
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (activeTab !== 'files') return
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
|
||||
function openConfirm(opts) {
|
||||
confirmTitle = opts.title || 'Подтверждение'
|
||||
confirmMessage = opts.message || ''
|
||||
confirmDanger = opts.danger !== undefined ? opts.danger : true
|
||||
confirmText = opts.confirmText || 'Удалить'
|
||||
confirmAction = opts.onConfirm || null
|
||||
cancelAction = opts.onCancel || null
|
||||
showConfirm = true
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
showConfirm = false
|
||||
confirmAction = null
|
||||
cancelAction = null
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmAction) confirmAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (cancelAction) cancelAction()
|
||||
closeConfirm()
|
||||
}
|
||||
|
||||
// ===== Node creation =====
|
||||
function openCreateNode() {
|
||||
showCreateNode = true
|
||||
|
|
@ -148,8 +496,21 @@
|
|||
|
||||
async function openNote(note) {
|
||||
if (noteEditor && noteEditor.dirty) {
|
||||
if (!confirm('Несохранённые изменения. Закрыть?')) return
|
||||
openConfirm({
|
||||
title: 'Несохранённые изменения',
|
||||
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||
confirmText: 'Закрыть',
|
||||
danger: false,
|
||||
onConfirm: async () => {
|
||||
await doOpenNote(note)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await doOpenNote(note)
|
||||
}
|
||||
|
||||
async function doOpenNote(note) {
|
||||
try {
|
||||
const content = await wailsCall('ReadNote', note.id)
|
||||
noteEditor = { id: note.id, title: note.title, content: content || '', dirty: false }
|
||||
|
|
@ -160,7 +521,14 @@
|
|||
|
||||
function closeNoteEditor() {
|
||||
if (noteEditor && noteEditor.dirty) {
|
||||
if (!confirm('Несохранённые изменения. Закрыть?')) return
|
||||
openConfirm({
|
||||
title: 'Несохранённые изменения',
|
||||
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
|
||||
confirmText: 'Закрыть',
|
||||
danger: false,
|
||||
onConfirm: () => { noteEditor = null }
|
||||
})
|
||||
return
|
||||
}
|
||||
noteEditor = null
|
||||
}
|
||||
|
|
@ -194,6 +562,94 @@
|
|||
worklogMinutes = ''
|
||||
}
|
||||
|
||||
// ===== Files =====
|
||||
async function addFile() {
|
||||
const path = await wailsCall('PickFile')
|
||||
if (!path) return
|
||||
await startImport(selectedNode.id, path)
|
||||
}
|
||||
|
||||
async function addFolder() {
|
||||
const path = await wailsCall('PickDirectory')
|
||||
if (!path) return
|
||||
await startImport(selectedNode.id, path)
|
||||
}
|
||||
|
||||
async function startImport(nodeID, sourcePath) {
|
||||
importing = true
|
||||
try {
|
||||
const summary = await wailsCall('PreviewImport', sourcePath)
|
||||
importSummary = summary
|
||||
pendingImportPath = sourcePath
|
||||
showImportDialog = true
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
importing = false
|
||||
}
|
||||
|
||||
async function confirmImport(mode) {
|
||||
try {
|
||||
const result = mode === 'copy'
|
||||
? await wailsCall('AddPathCopy', selectedNode.id, pendingImportPath)
|
||||
: await wailsCall('AddPathLink', selectedNode.id, pendingImportPath)
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
await Promise.all([
|
||||
loadTabData(selectedNode.id),
|
||||
loadFolder(selectedNode.id),
|
||||
])
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelImport() {
|
||||
showImportDialog = false
|
||||
importSummary = null
|
||||
}
|
||||
|
||||
async function deleteFile({ id, type }) {
|
||||
const label = type === 'folder' ? 'папку' : 'файл'
|
||||
openConfirm({
|
||||
title: 'Удаление',
|
||||
message: `Удалить ${label}?`,
|
||||
confirmText: 'Удалить',
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await wailsCall('DeleteFileOrFolder', id)
|
||||
files = files.filter(f => f.nodeId !== id)
|
||||
const reloadId = currentFolderId || selectedNode.id
|
||||
await loadFolder(reloadId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openSelectedFile(fileID) {
|
||||
try {
|
||||
await wailsCall('OpenFile', fileID)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Drag-and-drop =====
|
||||
async function onFilesDropped(paths) {
|
||||
if (!paths || paths.length === 0) return
|
||||
if (!selectedNode) {
|
||||
error = 'Сначала выберите дело для добавления файлов'
|
||||
return
|
||||
}
|
||||
const path = paths[0]
|
||||
await startImport(selectedNode.id, path)
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
||||
function formatDate(str) {
|
||||
|
|
@ -252,7 +708,12 @@
|
|||
|
||||
{#if error}
|
||||
<div class="error-banner" on:click={() => error = ''}>
|
||||
{error} <span class="dismiss">✕</span>
|
||||
{error}
|
||||
<button class="dismiss-btn" on:click|stopPropagation={() => error = ''} aria-label="Dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -275,7 +736,7 @@
|
|||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
{#each tabs as tab}
|
||||
<button class={tabClass(tab.id)} on:click={() => activeTab = tab.id}>{tab.label}</button>
|
||||
<button class={tabClass(tab.id)} on:click={() => { activeTab = tab.id; if (tab.id === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id) }}>{tab.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
|
|
@ -288,10 +749,22 @@
|
|||
<div class="meta-item"><span class="meta-label">Создано</span><span>{formatDate(selectedNode.createdAt)}</span></div>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>✏️ Новая заметка</button>
|
||||
<button class="qa-btn" disabled title="Следующий этап">📎 Добавить файл</button>
|
||||
<button class="qa-btn" disabled title="Следующий этап">⚡ Добавить действие</button>
|
||||
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>🕐 Записать время</button>
|
||||
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Новая заметка
|
||||
</button>
|
||||
<button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
Добавить файл
|
||||
</button>
|
||||
<button class="qa-btn" disabled title="Следующий этап">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Добавить действие
|
||||
</button>
|
||||
<button class="qa-btn" on:click={() => activeTab = 'worklog'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Записать время
|
||||
</button>
|
||||
</div>
|
||||
{#if notes.length > 0}
|
||||
<div class="recent-section">
|
||||
|
|
@ -343,15 +816,95 @@
|
|||
</div>
|
||||
|
||||
{:else if activeTab === 'files'}
|
||||
<div class="files-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={addFile} disabled={importing}>+ Добавить файл</button>
|
||||
<button class="btn" on:click={addFolder} disabled={importing}>+ Добавить папку</button>
|
||||
<button class="btn" on:click={createFile}>+ Новый файл</button>
|
||||
{#if clipboard.items.length > 0}
|
||||
<button class="btn" on:click={pasteItem}>Вставить {clipboard.items.length}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingFiles}
|
||||
<div class="empty-state">
|
||||
<p>Нет файлов</p>
|
||||
<p class="hint">Добавьте документы, скриншоты или папку с материалами.</p>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
currentFolderId = null
|
||||
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>
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
{/if}
|
||||
|
||||
{#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 ? 'В этой папке пока нет файлов' : 'В этом проекте пока нет файлов'}</p>
|
||||
<p class="hint">Добавьте файл или папку, чтобы сохранить материалы проекта.</p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn" disabled>+ Добавить файл</button>
|
||||
<button class="btn" disabled>+ Добавить папку</button>
|
||||
<button class="btn btn-primary" on:click={addFile}>Добавить файл</button>
|
||||
<button class="btn" on:click={addFolder}>Добавить папку</button>
|
||||
</div>
|
||||
<p class="empty-note">Полноценная работа с файлами — следующий этап.</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) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', 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)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if importing && !showImportDialog}
|
||||
<div class="empty-state"><p>Сканирование...</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if previewItem}
|
||||
<FilePreviewModal
|
||||
item={previewItem}
|
||||
content={previewContent}
|
||||
loading={previewLoading}
|
||||
error={previewError}
|
||||
on:close={closePreview}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{:else if activeTab === 'actions'}
|
||||
{#if actions.length === 0}
|
||||
|
|
@ -429,6 +982,41 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showImportDialog && importSummary}
|
||||
<div class="modal-overlay" on:click|self={cancelImport}>
|
||||
<div class="modal">
|
||||
<h3>Добавить в «{selectedNode ? selectedNode.title : ''}»</h3>
|
||||
<div class="import-summary">
|
||||
<div class="summary-row"><span>Файлов:</span><span>{importSummary.files}</span></div>
|
||||
<div class="summary-row"><span>Папок:</span><span>{importSummary.folders}</span></div>
|
||||
<div class="summary-row"><span>Размер:</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
|
||||
{#if importSummary.isDangerous}
|
||||
<div class="summary-warn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
{importSummary.dangerReason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>Скопировать</button>
|
||||
<button class="btn" on:click={() => confirmImport('link')}>Привязать</button>
|
||||
<button class="btn" on:click={cancelImport}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
message={confirmMessage}
|
||||
confirmText={confirmText}
|
||||
danger={confirmDanger}
|
||||
on:confirm={handleConfirm}
|
||||
on:cancel={handleCancel}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
@ -457,8 +1045,9 @@
|
|||
.crumb { font-size: 14px; font-weight: 500; }
|
||||
.crumb.placeholder { color: #666; }
|
||||
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
||||
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; }
|
||||
.dismiss { opacity: 0.6; }
|
||||
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
|
||||
.dismiss-btn:hover { color: #ff4444; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; padding: 0 24px; }
|
||||
|
|
@ -484,7 +1073,7 @@
|
|||
.meta-item { background: #1a1a28; padding: 12px 16px; border-radius: 8px; }
|
||||
.meta-label { display: block; font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
|
||||
.quick-actions { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; }
|
||||
.qa-btn { padding: 10px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.qa-btn:hover { background: #222233; }
|
||||
.qa-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.recent-section { margin-bottom: 24px; }
|
||||
|
|
@ -522,10 +1111,11 @@
|
|||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||
|
||||
/* Empty states */
|
||||
.empty-state { padding: 48px 24px; text-align: center; }
|
||||
.empty-state p { color: #666; margin-bottom: 8px; }
|
||||
.hint { font-size: 13px; color: #555; }
|
||||
.empty-actions { display: flex; gap: 8px; justify-content: center; margin: 16px 0; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
.empty-state p { margin: 0; font-size: 14px; color: #666; }
|
||||
.empty-state .empty-icon { margin-bottom: 12px; color: #444; }
|
||||
.empty-state .hint { font-size: 12px; color: #555; margin-top: 6px; }
|
||||
.empty-state .empty-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
|
||||
.empty-note { font-size: 12px; color: #444; margin-top: 16px; }
|
||||
|
||||
/* Welcome */
|
||||
|
|
@ -556,4 +1146,17 @@
|
|||
.btn-primary:hover { background: #4f46e5; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
|
||||
.btn-danger:hover { background: #3a2222; }
|
||||
|
||||
/* Files tab */
|
||||
.files-tab { padding: 20px; }
|
||||
.files-tab .tab-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.back-btn { margin-bottom: 4px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* Import summary */
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
export let onDragStart
|
||||
export let onDragOver
|
||||
export let onDrop
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
function handleClick(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
dispatch('toggleSelect', item.id)
|
||||
} else if (e.shiftKey) {
|
||||
dispatch('rangeSelect', item.id)
|
||||
} else {
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
function handleRename() {
|
||||
menuOpen = false
|
||||
dispatch('rename', { id: item.id, name: item.name })
|
||||
}
|
||||
|
||||
function handleDuplicate() {
|
||||
menuOpen = false
|
||||
dispatch('duplicate', item.id)
|
||||
}
|
||||
|
||||
function handleCut() {
|
||||
menuOpen = false
|
||||
dispatch('cut', item.id)
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
menuOpen = false
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen = false
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
if (onDragStart) onDragStart(e, item.id)
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (onDragOver && isFolder) onDragOver(e, item.id)
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#if !isFolder}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: default;
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.file-row--selected {
|
||||
background: #1e1e3a;
|
||||
outline: 1px solid #3a3a6c;
|
||||
}
|
||||
|
||||
.file-row--selected:hover {
|
||||
background: #252545;
|
||||
}
|
||||
|
||||
.file-row:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-row-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.file-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-row-name {
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.file-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.file-row-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-row:hover .file-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #3a2222;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,6 +15,22 @@ export const readNote = (noteID) => App.ReadNote(noteID)
|
|||
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
|
||||
|
||||
export const listFiles = (nodeID) => App.ListFiles(nodeID)
|
||||
export const listItems = (nodeID) => App.ListItems(nodeID)
|
||||
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
|
||||
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
|
||||
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
|
||||
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
|
||||
export const pickFile = () => App.PickFile()
|
||||
export const pickFiles = () => App.PickFiles()
|
||||
export const pickDirectory = () => App.PickDirectory()
|
||||
export const openFile = (id) => App.OpenFile(id)
|
||||
export const readFileText = (id) => App.ReadFileText(id)
|
||||
export const getFileBase64 = (id) => App.GetFileBase64(id)
|
||||
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
|
||||
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||
|
||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||
export const runAction = (id) => App.RunAction(id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
<script>
|
||||
export let title = 'Подтверждение'
|
||||
export let message = ''
|
||||
export let confirmText = 'Удалить'
|
||||
export let cancelText = 'Отмена'
|
||||
export let danger = false
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div class="modal">
|
||||
<h3>{title}</h3>
|
||||
<p class="message">{message}</p>
|
||||
<div class="actions">
|
||||
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
|
||||
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
color: #e4e4ef;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #2a2a3c;
|
||||
background: #1a1a28;
|
||||
color: #ccc;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #222233;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
border-color: #6366f1;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let isFolder = false
|
||||
export let fileId = ''
|
||||
export let nodeId = ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function handleOpen() {
|
||||
if (isFolder) {
|
||||
dispatch('openFolder', nodeId)
|
||||
} else {
|
||||
dispatch('open', fileId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete', nodeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-actions">
|
||||
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#if isFolder}
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
{:else}
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.file-row:hover) .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn-danger:hover {
|
||||
background: #3a2222;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let crumbs = []
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function navigateTo(index) {
|
||||
dispatch('navigate', index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumbs">
|
||||
{#each crumbs as crumb, i}
|
||||
{#if i > 0}
|
||||
<span class="sep">/</span>
|
||||
{/if}
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="crumb crumb--current">{crumb.name}</span>
|
||||
{:else}
|
||||
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.crumb--current {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.crumb--link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.crumb--link:hover {
|
||||
color: #ccc;
|
||||
background: #1e1e30;
|
||||
}
|
||||
|
||||
.crumb--link:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<script>
|
||||
export let kind = 'generic'
|
||||
export let size = 20
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
{#if kind === 'folder'}
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
{:else if kind === 'image'}
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
{:else if kind === 'video'}
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<polyline points="10 9 16 12 10 15 10 9"/>
|
||||
{:else if kind === 'audio'}
|
||||
<path d="M9 18V5l12-2v13"/>
|
||||
<circle cx="6" cy="18" r="3"/>
|
||||
<circle cx="18" cy="16" r="3"/>
|
||||
{:else if kind === 'pdf'}
|
||||
<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"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||
{:else if kind === 'document'}
|
||||
<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"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
{:else if kind === 'spreadsheet'}
|
||||
<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"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
<line x1="8" y1="16" x2="16" y2="16"/>
|
||||
<line x1="8" y1="14" x2="12" y2="14"/>
|
||||
<line x1="12" y1="12" x2="12" y2="18"/>
|
||||
{:else if kind === 'presentation'}
|
||||
<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"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||
<line x1="9" y1="15" x2="13" y2="15"/>
|
||||
<line x1="12" y1="15" x2="12" y2="18"/>
|
||||
{:else if kind === 'archive'}
|
||||
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
|
||||
<polyline points="7 3 12 8 17 3"/>
|
||||
<line x1="3" y1="8" x2="21" y2="8"/>
|
||||
<rect x="10" y="12" width="4" height="4" rx="1"/>
|
||||
{:else if kind === 'code'}
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
{:else if kind === 'text'}
|
||||
<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"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
{:else}
|
||||
<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"/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||
import FileIcon from './FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let content = '' // base64 data URI or text content
|
||||
export let loading = false
|
||||
export let error = ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const kind = getFileKind(item)
|
||||
$: showImage = isImageFile(item) && content && content.startsWith('data:')
|
||||
$: showText = isTextFile(item) || isMarkdownFile(item)
|
||||
$: showPdf = isPdfFile(item)
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
dispatch('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
|
||||
<div class="modal">
|
||||
<header class="preview-header">
|
||||
<div class="preview-title">
|
||||
<FileIcon {kind} size={18}/>
|
||||
<span class="preview-name" title={item.name}>{item.name}</span>
|
||||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<div class="preview-actions">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
{:else if error}
|
||||
<div class="preview-status">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
<img src={content} alt={item.name} class="preview-image"/>
|
||||
</div>
|
||||
{:else if showText && content}
|
||||
<pre class="preview-text"><code>{content}</code></pre>
|
||||
{:else if showPdf}
|
||||
{#if content && content.startsWith('data:')}
|
||||
<div class="preview-pdf-container">
|
||||
<embed src={content} type="application/pdf" class="preview-pdf"/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>PDF preview is not available in this environment.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>Preview is not available for this file type.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #14141f;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 10px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
height: 85vh;
|
||||
max-height: 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2a2a3c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #ddd;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #2a2a3c;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action-btn-close {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.action-btn-close:hover {
|
||||
background: #3a2222;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-image-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
background: #0e0e18;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: calc(85vh - 100px);
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ccc;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-pdf-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-pdf {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #2a2a3c;
|
||||
background: #1a1a28;
|
||||
color: #ccc;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: #222233;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
export function formatFileSize(bytes) {
|
||||
if (bytes == null || bytes < 0) return '—'
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const val = bytes / Math.pow(1024, i)
|
||||
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const mimeLabels = {
|
||||
'image/jpeg': 'JPEG image',
|
||||
'image/png': 'PNG image',
|
||||
'image/gif': 'GIF image',
|
||||
'image/webp': 'WebP image',
|
||||
'image/svg+xml': 'SVG image',
|
||||
'image/bmp': 'BMP image',
|
||||
'image/tiff': 'TIFF image',
|
||||
'image/avif': 'AVIF image',
|
||||
'application/pdf': 'PDF document',
|
||||
'application/msword': 'Word document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
||||
'application/zip': 'ZIP archive',
|
||||
'application/gzip': 'GZIP archive',
|
||||
'application/x-tar': 'TAR archive',
|
||||
'application/x-7z-compressed': '7z archive',
|
||||
'application/x-rar-compressed': 'RAR archive',
|
||||
'text/plain': 'Text file',
|
||||
'text/html': 'HTML file',
|
||||
'text/css': 'CSS file',
|
||||
'text/javascript': 'JavaScript file',
|
||||
'application/json': 'JSON file',
|
||||
'application/xml': 'XML file',
|
||||
'application/x-yaml': 'YAML file',
|
||||
'application/octet-stream': 'Binary file',
|
||||
'application/x-msdos-program': 'Executable',
|
||||
'inode/directory': 'Folder',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mime.startsWith('image/')) return 'image'
|
||||
if (mime.startsWith('video/')) return 'video'
|
||||
if (mime.startsWith('audio/')) return 'audio'
|
||||
if (mime.startsWith('text/')) return 'text'
|
||||
if (mime.includes('pdf')) return 'pdf'
|
||||
if (mime.includes('word') || mime.includes('document')) return 'document'
|
||||
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
|
||||
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
|
||||
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
|
||||
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
|
||||
if (codeExts.includes(ext)) return 'code'
|
||||
return 'generic'
|
||||
}
|
||||
|
||||
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
|
||||
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
|
||||
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
|
||||
|
||||
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
|
||||
|
||||
export function canPreviewFile(item) {
|
||||
if (item.type === 'folder') return false
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
|
||||
if (mime.includes('pdf')) return true
|
||||
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isImageFile(item) {
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
return imageMimes.includes(mime) || imageExts.includes(ext)
|
||||
}
|
||||
|
||||
export function isTextFile(item) {
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
|
||||
}
|
||||
|
||||
export function isPdfFile(item) {
|
||||
return (item.mime || '').toLowerCase().includes('pdf')
|
||||
}
|
||||
|
||||
export function isMarkdownFile(item) {
|
||||
const name = (item.name || '').toLowerCase()
|
||||
return name.endsWith('.md') || name.endsWith('.markdown')
|
||||
}
|
||||
|
||||
export function needsBase64Preview(item) {
|
||||
return isImageFile(item) || isPdfFile(item)
|
||||
}
|
||||
|
||||
export function needsTextPreview(item) {
|
||||
return isTextFile(item) || isMarkdownFile(item)
|
||||
}
|
||||
|
|
@ -46,6 +46,22 @@ export function ListFiles(arg1) {
|
|||
return window['go']['main']['App']['ListFiles'](arg1);
|
||||
}
|
||||
|
||||
export function AddPathCopy(arg1, arg2) {
|
||||
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function AddPathLink(arg1, arg2) {
|
||||
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function DeleteFileOrFolder(arg1) {
|
||||
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
|
||||
}
|
||||
|
||||
export function PreviewImport(arg1) {
|
||||
return window['go']['main']['App']['PreviewImport'](arg1);
|
||||
}
|
||||
|
||||
export function ListActions(arg1) {
|
||||
return window['go']['main']['App']['ListActions'](arg1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package files
|
|||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
|
@ -32,15 +34,25 @@ type Record struct {
|
|||
Missing bool `json:"missing"`
|
||||
}
|
||||
|
||||
// ImportSummary describes a scanned directory before import.
|
||||
type ImportSummary struct {
|
||||
Files int `json:"files"`
|
||||
Folders int `json:"folders"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
IsDangerous bool `json:"isDangerous"`
|
||||
DangerReason string `json:"dangerReason,omitempty"`
|
||||
}
|
||||
|
||||
// Service provides file operations inside a vault.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
}
|
||||
|
||||
// NewService creates a file service bound to a vault.
|
||||
func NewService(db *storage.DB, vaultRoot string) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot}
|
||||
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
|
||||
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
|
||||
}
|
||||
|
||||
// DB returns the underlying storage.
|
||||
|
|
@ -166,6 +178,280 @@ func (s *Service) Open(id string) error {
|
|||
return openWithSystem(abs)
|
||||
}
|
||||
|
||||
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
|
||||
const maxPreviewSize = 5 * 1024 * 1024
|
||||
|
||||
// ReadText reads a file's content as text, up to maxPreviewSize.
|
||||
func (s *Service) ReadText(id string) (string, error) {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// ReadBase64 reads a file and returns a data URI (base64-encoded).
|
||||
func (s *Service) ReadBase64(id string) (string, error) {
|
||||
rec, err := s.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read: %w", err)
|
||||
}
|
||||
mime := rec.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
|
||||
}
|
||||
|
||||
// CreateEmptyFile creates a file node and an empty vault file.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
dest := filepath.Join(dir, filename)
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
|
||||
// Duplicate creates a copy of a node and its file record under the same parent.
|
||||
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
original, err := s.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentID := ""
|
||||
if original.ParentID != nil {
|
||||
parentID = *original.ParentID
|
||||
}
|
||||
newName := s.uniqueTitle(parentID, original.Title)
|
||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if original.Type == nodes.TypeFile {
|
||||
records, _ := s.ListByNode(original.ID)
|
||||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
if src.StorageMode == "vault" {
|
||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
dst := filepath.Join(dir, newName)
|
||||
hash, err := copyAndHash(srcPath, dst)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// External file: create a new record pointing to the same absolute path.
|
||||
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
|
||||
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||
return s.importPath(nodeID, sourcePath, true)
|
||||
}
|
||||
|
||||
// AddPathLink links sourcePath (file or directory) without copying into vault.
|
||||
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
|
||||
return s.importPath(nodeID, sourcePath, false)
|
||||
}
|
||||
|
||||
// PreviewImport scans sourcePath and returns a summary without importing.
|
||||
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
|
||||
}
|
||||
|
||||
var sum ImportSummary
|
||||
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if fi.IsDir() {
|
||||
sum.Folders++
|
||||
name := strings.ToLower(fi.Name())
|
||||
if name == ".git" || name == "node_modules" || name == ".cache" {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sum.Files++
|
||||
sum.TotalBytes += fi.Size()
|
||||
return nil
|
||||
})
|
||||
if sum.Files > 1000 && !sum.IsDangerous {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = "более 1000 файлов"
|
||||
}
|
||||
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
|
||||
sum.IsDangerous = true
|
||||
sum.DangerReason = "более 1 GB"
|
||||
}
|
||||
return &sum, err
|
||||
}
|
||||
|
||||
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
||||
// moving vault files to trash.
|
||||
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||
children, _ := s.nodes.ListChildren(nodeID, false)
|
||||
for i := range children {
|
||||
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
return s.nodes.SoftDelete(nodeID)
|
||||
}
|
||||
|
||||
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||
records, err := s.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range records {
|
||||
_ = s.DeleteToTrash(r.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if copyMode {
|
||||
_, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug)
|
||||
} else {
|
||||
_, err = s.AddExternal(node.ID, sourcePath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []nodes.Node{*node}, nil
|
||||
}
|
||||
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
|
||||
}
|
||||
|
||||
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
|
||||
dirName = s.uniqueTitle(parentID, dirName)
|
||||
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourcePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var all []nodes.Node
|
||||
all = append(all, *folderNode)
|
||||
|
||||
for _, entry := range entries {
|
||||
childPath := filepath.Join(sourcePath, entry.Name())
|
||||
if entry.IsDir() {
|
||||
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, children...)
|
||||
} else {
|
||||
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if copyMode {
|
||||
_, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug)
|
||||
} else {
|
||||
_, err = s.AddExternal(childNode.ID, childPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, *childNode)
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (s *Service) uniqueTitle(parentID, desired string) string {
|
||||
children, _ := s.nodes.ListChildren(parentID, false)
|
||||
used := make(map[string]bool, len(children))
|
||||
for i := range children {
|
||||
used[children[i].Title] = true
|
||||
}
|
||||
if !used[desired] {
|
||||
return desired
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
c := fmt.Sprintf("%s (%d)", desired, n)
|
||||
if !used[c] {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
|
|||
db := openTestDB(t)
|
||||
// Run migration 002 manually since storage.Open already applied it.
|
||||
// We can verify the table exists by inserting.
|
||||
filesSvc := NewService(db, t.TempDir())
|
||||
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||
|
||||
// Create a real temp file to register.
|
||||
tmpDir := t.TempDir()
|
||||
|
|
@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
|
|||
func TestCopyIntoVault(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
// Source file.
|
||||
srcDir := t.TempDir()
|
||||
|
|
@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
|
|||
|
||||
func TestListByNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db, t.TempDir())
|
||||
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
|
||||
|
||||
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
|
||||
f1 := filepath.Join(t.TempDir(), "a1.txt")
|
||||
|
|
@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
|
|||
func TestDeleteToTrash(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot)
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
src := filepath.Join(t.TempDir(), "important.pdf")
|
||||
os.WriteFile(src, []byte("important data"), 0o640)
|
||||
|
|
@ -140,6 +141,170 @@ func TestDeleteToTrash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddPathCopySingleFile(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
src := filepath.Join(t.TempDir(), "doc.pdf")
|
||||
os.WriteFile(src, []byte("file content"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathCopy(parent.ID, src)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathCopy: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].Type != "file" {
|
||||
t.Errorf("type = %q", nodes[0].Type)
|
||||
}
|
||||
// Source intact.
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Error("source should remain intact")
|
||||
}
|
||||
// File record created.
|
||||
records, _ := svc.ListByNode(nodes[0].ID)
|
||||
if len(records) != 1 {
|
||||
t.Errorf("file records = %d", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPathLinkSingleFile(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
src := filepath.Join(t.TempDir(), "linked.pdf")
|
||||
os.WriteFile(src, []byte("linked"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathLink(parent.ID, src)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathLink: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
// File record should have external storage mode.
|
||||
records, _ := svc.ListByNode(nodes[0].ID)
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("file records = %d", len(records))
|
||||
}
|
||||
if records[0].StorageMode != "external" {
|
||||
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPathCopyDirectory(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
|
||||
srcDir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
|
||||
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
|
||||
|
||||
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
|
||||
if err != nil {
|
||||
t.Fatalf("AddPathCopy dir: %v", err)
|
||||
}
|
||||
// Should create: folder node + file node + sub folder node + file node in sub.
|
||||
if len(nodes) < 3 {
|
||||
t.Errorf("expected 3+ nodes, got %d", len(nodes))
|
||||
}
|
||||
// Verify structure: root folder + children.
|
||||
var folders, files int
|
||||
for i := range nodes {
|
||||
if nodes[i].Type == "folder" {
|
||||
folders++
|
||||
} else {
|
||||
files++
|
||||
}
|
||||
}
|
||||
if folders < 1 {
|
||||
t.Error("expected at least 1 folder")
|
||||
}
|
||||
if files < 1 {
|
||||
t.Error("expected at least 1 file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNodeAndChildren(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
|
||||
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
|
||||
// Add file record to child.
|
||||
src := filepath.Join(t.TempDir(), "child.txt")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
svc.CopyIntoVault(child.ID, src, child.Slug)
|
||||
|
||||
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
|
||||
t.Fatalf("DeleteNodeAndChildren: %v", err)
|
||||
}
|
||||
// Parent should be soft-deleted.
|
||||
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
|
||||
t.Error("parent should be deleted")
|
||||
}
|
||||
// Child should be soft-deleted.
|
||||
if _, err := nodeRepo.GetActive(child.ID); err == nil {
|
||||
t.Error("child should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameConflict(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
svc := NewService(db, vaultRoot, nodeRepo)
|
||||
|
||||
parent, _ := nodeRepo.Create("", "case", "Test", "")
|
||||
src := filepath.Join(t.TempDir(), "conflict.pdf")
|
||||
os.WriteFile(src, []byte("data"), 0o640)
|
||||
|
||||
// Import twice with same filename.
|
||||
n1, _ := svc.AddPathCopy(parent.ID, src)
|
||||
n2, _ := svc.AddPathCopy(parent.ID, src)
|
||||
if n1[0].Title == n2[0].Title {
|
||||
t.Error("expected unique name on conflict")
|
||||
}
|
||||
if n2[0].Title == "conflict.pdf" {
|
||||
t.Errorf("title unchanged = %q", n2[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewImportDir(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
vaultRoot := t.TempDir()
|
||||
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
|
||||
|
||||
srcDir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
|
||||
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
|
||||
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
|
||||
|
||||
sum, err := svc.PreviewImport(srcDir)
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewImport: %v", err)
|
||||
}
|
||||
if sum.Files != 2 {
|
||||
t.Errorf("files = %d, want 2", sum.Files)
|
||||
}
|
||||
if sum.Folders != 2 { // root + sub
|
||||
t.Errorf("folders = %d, want 2", sum.Folders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessMIME(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a.md": "text/plain",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
|
|||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, dir)
|
||||
fileSvc := files.NewService(db, dir, nodeRepo)
|
||||
svc := NewService(db, dir, nodeRepo, fileSvc)
|
||||
return svc, nodeRepo, dir
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ type Server struct {
|
|||
// NewServer creates a GUI server for the given vault.
|
||||
func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot)
|
||||
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
|
||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
workSvc := worklog.NewService(db)
|
||||
|
|
|
|||
Loading…
Reference in New Issue