Files: Move… opens a folder picker, and a move across filesystems copies then removes
The picker walks the machine like the tab does — back, forward, up, breadcrumbs, a path box, a new folder — and "Move here" puts the file or folder in the folder being looked at; it says why it can't when it can't (already here, into itself, a name clash). POST /api/files/move is a rename when the two ends share a filesystem and otherwise a task: on a NAS every move between datasets is a different filesystem, and a rename there is EXDEV. The copy is built under a hidden temporary name beside its destination, renamed into place once complete, and only then is the original removed, so a failure or a cancel leaves the original as it was. Modes, times, symlinks and, as root, owners are kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e5d3fb2afd
commit
fb3b8e4b4c
+96
-4
@@ -1,5 +1,5 @@
|
||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo, ThumbStatus } from './api'
|
||||
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||
import { useHashSel } from './hashsel'
|
||||
@@ -25,6 +25,95 @@ function icon(e: FileEntry): string {
|
||||
|
||||
type Sort = 'name' | 'size' | 'mtime'
|
||||
const PREVIEWS_KEY = 'hamlab.files.previews'
|
||||
const byName = (a: FileEntry, b: FileEntry) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
|
||||
// ---- move: walk to the destination, then "Move here" ----
|
||||
function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: FileEntry; from: string; sudo: boolean; hidden: boolean; onClose: () => void; onMoved: (task?: TaskInfo) => void }) {
|
||||
const src = join(from, item.name)
|
||||
const isFolder = item.type === 'dir' && !item.target
|
||||
const [hist, setHist] = useState<string[]>([from]) // where the picker has been, for back and forward
|
||||
const [at, setAt] = useState(0)
|
||||
const dir = hist[at]
|
||||
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [pathInput, setPathInput] = useState(from)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const load = useCallback(async (p: string) => {
|
||||
setEntries(null); setErr('')
|
||||
try { const l = await api.files(p, sudo); setEntries(l.entries) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setEntries([]) }
|
||||
}, [sudo])
|
||||
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
|
||||
useEscape(onClose)
|
||||
const go = (p: string) => {
|
||||
p = p.trim().replace(/\/+$/, '') || '/'
|
||||
if (!p.startsWith('/') || p === dir) return
|
||||
setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1)
|
||||
}
|
||||
const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') }
|
||||
const folders = (entries ?? []).filter((e) => e.type === 'dir' && (hidden || !e.name.startsWith('.'))).sort(byName)
|
||||
const crumbs = dir === '/' ? [] : dir.slice(1).split('/')
|
||||
const dest = join(dir, item.name)
|
||||
const why = dir === from ? 'it is already here'
|
||||
: isFolder && (dir === src || dir.startsWith(src + '/')) ? 'a folder can\'t move into itself'
|
||||
: (entries ?? []).some((e) => e.name === item.name) ? `something called ${item.name} is already there` : ''
|
||||
const move = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const t = await api.fileMove(src, dest, sudo)
|
||||
if (t) toast(`${dir} is on another filesystem — copying ${item.name} there, then removing it here`, 'info')
|
||||
else toast(`moved ${item.name} to ${dir}`)
|
||||
onMoved(t)
|
||||
} catch (e) { toast(`move — ${e instanceof Error ? e.message : 'failed'}`, 'err'); setBusy(false) }
|
||||
}
|
||||
const mkdir = async () => {
|
||||
const n = await askText('New folder name')
|
||||
if (!okName(n)) return
|
||||
try { await api.fileMkdir(join(dir, n.trim()), sudo); go(join(dir, n.trim())) } catch (e) { toast(`create folder — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal mover" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Move {isFolder ? 'folder ' : ''}<span className="mono">{item.name}</span></h3>
|
||||
<div className="mover-nav">
|
||||
<button className="icon-btn" title="back" disabled={at === 0} onClick={() => setAt(at - 1)}>‹</button>
|
||||
<button className="icon-btn" title="forward" disabled={at >= hist.length - 1} onClick={() => setAt(at + 1)}>›</button>
|
||||
<button className="icon-btn" title="up one level" disabled={dir === '/'} onClick={up}>↑</button>
|
||||
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput) }}>
|
||||
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
|
||||
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
|
||||
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
</form>
|
||||
</div>
|
||||
<div className="mover-list">
|
||||
{entries === null && <Loading label="Listing…" />}
|
||||
{err && <div className="error" style={{ margin: 10 }}>{err}</div>}
|
||||
{folders.map((e) => {
|
||||
const self = dir === from && e.name === item.name
|
||||
return (
|
||||
<div key={e.name} className={'mover-row' + (self ? ' self' : '')} onClick={() => { if (!self) go(join(dir, e.name)) }}>
|
||||
<span className="ficon">{e.target ? '🔗' : '📁'}</span>
|
||||
<span className="fname">{e.name}</span>
|
||||
{self && <span className="muted small">this is what's moving</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries !== null && folders.length === 0 && !err && <div className="muted small" style={{ padding: 12 }}>No folders here — “Move here” puts it in this one.</div>}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<div className="svc-actions">
|
||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<Btn className="btn on" disabled={!!why || entries === null} busy={busy} onClick={move}>Move here</Btn>
|
||||
</div>
|
||||
<div className="muted small mono mover-dest">{why || `→ ${dest}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- previews ----
|
||||
|
||||
@@ -184,6 +273,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const [pathInput, setPathInput] = useState(path)
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [menu, setMenu] = useState<string | null>(null)
|
||||
const [moving, setMoving] = useState<FileEntry | null>(null)
|
||||
const [moveTask, setMoveTask] = useState<TaskInfo | null>(null)
|
||||
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
|
||||
const [drag, setDrag] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
@@ -227,12 +318,10 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
||||
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||||
}
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
|
||||
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const move = (e: FileEntry) => setMoving(e)
|
||||
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||
const del = async (e: FileEntry) => {
|
||||
const full = join(path, e.name)
|
||||
@@ -292,6 +381,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
</div>
|
||||
|
||||
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
|
||||
{moveTask && <TaskOutput id={moveTask.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${moveTask.title}: done`); else toast(`${moveTask.title}: failed`, 'err') }} />}
|
||||
{moveTask && <div className="thumb-note"><button className="btn" onClick={() => setMoveTask(null)}>Hide output</button></div>}
|
||||
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
|
||||
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
|
||||
|
||||
@@ -330,6 +421,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
|
||||
</div>
|
||||
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
|
||||
{moving && <MovePicker item={moving} from={path} sudo={sudo} hidden={hidden} onClose={() => setMoving(null)} onMoved={(t) => { setMoving(null); if (t) setMoveTask(t); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user