File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
This commit is contained in:
parent
2487d3bbaa
commit
a4ae22c445
|
|
@ -315,7 +315,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
return a.nodes.UpdateTitle(nodeID, newTitle)
|
||||
}
|
||||
|
||||
func (a *App) ValidateName(name string) error {
|
||||
return files.ValidateName(name)
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
// Check for name conflict at destination
|
||||
destChildren, err := a.nodes.ListChildren(newParentID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range destChildren {
|
||||
if destChildren[i].Title == node.Title {
|
||||
// Conflict: auto-rename
|
||||
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
|
||||
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return a.nodes.Move(nodeID, newParentID, 0)
|
||||
}
|
||||
|
||||
|
|
|
|||
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-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
<script type="module" crossorigin src="/assets/main-B8EIu1OK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@
|
|||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
|
|
@ -282,19 +287,8 @@
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
|
|
@ -343,6 +337,20 @@
|
|||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
|
|
@ -421,15 +429,106 @@
|
|||
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() }
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
// Backspace without selection → navigate up
|
||||
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||
e.preventDefault()
|
||||
navigateBack()
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
openPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||
// Validate name via backend
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = 'Недопустимое имя'
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', renameId, name)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
|
||||
function openConfirm(opts) {
|
||||
|
|
@ -832,7 +931,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
|
|
@ -847,7 +946,7 @@
|
|||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
|
|
@ -877,6 +976,7 @@
|
|||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
|
|
@ -884,6 +984,7 @@
|
|||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1007,6 +1108,26 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||
<div class="modal">
|
||||
<h3>Переименовать</h3>
|
||||
<div class="form-group">
|
||||
<label>Новое имя</label>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
|
|
@ -1159,4 +1280,6 @@
|
|||
.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; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
const fileType = formatFileType(item)
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
// Double click → открыть
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
|
|
@ -34,37 +35,23 @@
|
|||
} 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)
|
||||
}
|
||||
// Single click → выделить
|
||||
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 handleShowInFolder() {
|
||||
dispatch('showInFolder', item.id)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
menuOpen = false
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +75,15 @@
|
|||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
menuOpen = false
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
|
|
@ -107,48 +103,48 @@
|
|||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
|
||||
function oncontextmenu(e) {
|
||||
e.preventDefault()
|
||||
menuOpen = true
|
||||
}
|
||||
</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:contextmenu={oncontextmenu}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${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>{fileType}</span>
|
||||
{#if !isFolder}
|
||||
<span class="meta-sep">·</span>
|
||||
<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">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||
<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">
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<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"/>
|
||||
|
|
@ -156,21 +152,21 @@
|
|||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||
<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}>
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" 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">
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||
<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"/>
|
||||
|
|
@ -183,10 +179,42 @@
|
|||
<!-- 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>
|
||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||
<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="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="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||
<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="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>
|
||||
{#if isFolder}
|
||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||
<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="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"/></svg>
|
||||
Показать в проводнике
|
||||
</button>
|
||||
{/if}
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||
<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="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="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Дублировать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||
Вырезать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Копировать
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||
<svg width="14" height="14" 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>
|
||||
{/if}
|
||||
|
||||
|
|
@ -201,6 +229,7 @@
|
|||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
|
|
@ -314,17 +343,19 @@
|
|||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
min-width: 220px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
|
|
@ -337,10 +368,25 @@
|
|||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: #3a2222;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: #2a2a3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
</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">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<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"/>
|
||||
|
|
@ -60,11 +60,11 @@
|
|||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
<div class="preview-status"><p>Загрузка...</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>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
|
|
@ -79,14 +79,14 @@
|
|||
</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>
|
||||
<p>Предпросмотр PDF недоступен.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</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>
|
||||
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
|||
}
|
||||
|
||||
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',
|
||||
'image/jpeg': 'Изображение JPEG',
|
||||
'image/png': 'Изображение PNG',
|
||||
'image/gif': 'Изображение GIF',
|
||||
'image/webp': 'Изображение WebP',
|
||||
'image/svg+xml': 'Изображение SVG',
|
||||
'image/bmp': 'Изображение BMP',
|
||||
'image/tiff': 'Изображение TIFF',
|
||||
'image/avif': 'Изображение AVIF',
|
||||
'application/pdf': 'PDF документ',
|
||||
'application/msword': 'Документ Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||
'application/vnd.ms-excel': 'Таблица Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||
'application/zip': 'ZIP архив',
|
||||
'application/gzip': 'GZIP архив',
|
||||
'application/x-tar': 'TAR архив',
|
||||
'application/x-7z-compressed': '7z архив',
|
||||
'application/x-rar-compressed': 'RAR архив',
|
||||
'text/plain': 'Текстовый файл',
|
||||
'text/html': 'HTML файл',
|
||||
'text/css': 'CSS файл',
|
||||
'text/javascript': 'JavaScript файл',
|
||||
'application/json': 'JSON файл',
|
||||
'application/xml': 'XML файл',
|
||||
'application/x-yaml': 'YAML файл',
|
||||
'application/octet-stream': 'Бинарный файл',
|
||||
'application/x-msdos-program': 'Исполняемый файл',
|
||||
'inode/directory': 'Папка',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
if (!mime) return 'Неизвестно'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function formatFileType(item) {
|
||||
if (item.type === 'folder') return 'Папка'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (ext) return ext.toUpperCase()
|
||||
return 'Файл'
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
|
|
|
|||
|
|
@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
|||
return s.db
|
||||
}
|
||||
|
||||
// --- security helpers ---
|
||||
|
||||
// ValidateName is an exported wrapper for validateName.
|
||||
func ValidateName(name string) error {
|
||||
return validateName(name)
|
||||
}
|
||||
|
||||
// validateName rejects filenames with path separators, relative components,
|
||||
// and other dangerous patterns.
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("name must not contain path separators")
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("name must not contain '..'")
|
||||
}
|
||||
if strings.Contains(name, "\x00") {
|
||||
return fmt.Errorf("name must not contain null bytes")
|
||||
}
|
||||
if len(name) > 255 {
|
||||
return fmt.Errorf("name too long (max 255)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||
func (s *Service) vaultPath(rel string) (string, error) {
|
||||
abs := filepath.Join(s.vaultRoot, rel)
|
||||
cleaned := filepath.Clean(abs)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||
return "", fmt.Errorf("path escapes vault root")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||
// For "external" mode records the path stored may be an absolute external path.
|
||||
// This function only checks path safety — it does not enforce that external
|
||||
// files must be inside the vault.
|
||||
func (s *Service) absPathSafe(rec *Record) (string, error) {
|
||||
if rec.StorageMode == "vault" {
|
||||
return s.vaultPath(rec.Path)
|
||||
}
|
||||
abs, err := filepath.Abs(rec.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs: %w", err)
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
// --- public operations ---
|
||||
|
||||
// AddExternal registers an external file (absolute path) without copying.
|
||||
|
|
@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||
}
|
||||
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
|
@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
|||
}
|
||||
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||
}
|
||||
|
||||
|
|
@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
|||
return err
|
||||
}
|
||||
if rec.StorageMode == "vault" {
|
||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
||||
src, err := s.vaultPath(rec.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||
// verify trash is inside vault
|
||||
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
|
|
@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
|
|
@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
|||
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
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
|
|
@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||
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
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
|
|
@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
|||
|
||||
// CreateEmptyFile creates a file node and an empty vault file.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
if err := validateName(filename); err != nil {
|
||||
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||
}
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
|
|
@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
|||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
// Verify dest is inside vault
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
|
|
@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||
if original.ParentID != nil {
|
||||
parentID = *original.ParentID
|
||||
}
|
||||
newName := s.uniqueTitle(parentID, original.Title)
|
||||
newName := s.copyTitle(parentID, original.Title)
|
||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
if src.StorageMode == "vault" {
|
||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
||||
srcPath, err := s.vaultPath(src.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
dst := filepath.Join(dir, newName)
|
||||
|
|
@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
|||
return nil, fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
|
|||
}
|
||||
}
|
||||
|
||||
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
|
||||
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
|
||||
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
|
||||
func (s *Service) copyTitle(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
|
||||
}
|
||||
|
||||
ext := filepath.Ext(desired)
|
||||
base := strings.TrimSuffix(desired, ext)
|
||||
copyName := base + " (copy)" + ext
|
||||
if !used[copyName] {
|
||||
return copyName
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
|
||||
if !used[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
|
||||
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
|
||||
return s.copyTitle(parentID, desired)
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue