File browser: folder upload + copy/move (0.13.0)
Folder upload: the Files page gained an "Upload folder" picker
(webkitdirectory); each file is sent with its webkitRelativePath and the
backend recreates the directory tree. upload_target now accepts an optional
rel_path, creating intermediate dirs (mkdir -p) inside the sandbox with each
component validated against traversal.
Copy/move: new file_service.copy/move + POST /api/files/{copy,move}
(admin, audit-logged). The UI adds per-row copy/cut actions, a clipboard bar
to paste into the current directory, and an overwrite prompt on conflict.
Both refuse to move/copy a folder into itself or its own subtree and are
sandbox-checked on source and destination.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e3313fb4ac
commit
25bba1cf2c
@@ -154,13 +154,17 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
highlighting picked from the extension). Binary and oversized files are
|
highlighting picked from the extension). Binary and oversized files are
|
||||||
detected and offered as a download instead. Admins can edit and **Save**.
|
detected and offered as a download instead. Admins can edit and **Save**.
|
||||||
- **Manage** (admin): create folders/files, rename, delete (recursive for
|
- **Manage** (admin): create folders/files, rename, delete (recursive for
|
||||||
folders), upload files, and download any file. Every mutation is audit-logged.
|
folders), upload files **or whole folders** (the directory tree is recreated
|
||||||
|
server-side), and download any file. **Copy/cut & paste** moves files and
|
||||||
|
folders between directories (clipboard bar + per-row copy/cut, with an
|
||||||
|
overwrite prompt on conflict). Every mutation is audit-logged.
|
||||||
- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
|
- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
|
||||||
traversal and deleting a browse root are refused. To reach the real host
|
traversal and deleting a browse root are refused. To reach the real host
|
||||||
filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
|
filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
|
||||||
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
|
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
|
||||||
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `upload`,
|
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`,
|
||||||
`download`, `DELETE`).
|
`move`, `upload` — with optional `rel_path` for folder uploads —, `download`,
|
||||||
|
`DELETE`).
|
||||||
|
|
||||||
## Deploying an agent on another host
|
## Deploying an agent on another host
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from services import backup_service, compose_service
|
|||||||
|
|
||||||
logger = logging.getLogger("stackpilot.agent")
|
logger = logging.getLogger("stackpilot.agent")
|
||||||
|
|
||||||
AGENT_VERSION = "0.12.0"
|
AGENT_VERSION = "0.13.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.12.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.13.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ class RenameBody(BaseModel):
|
|||||||
new_name: str
|
new_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TransferBody(BaseModel):
|
||||||
|
src: str
|
||||||
|
dest_dir: str
|
||||||
|
overwrite: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.put("/write")
|
@router.put("/write")
|
||||||
def write_file(
|
def write_file(
|
||||||
body: WriteBody,
|
body: WriteBody,
|
||||||
@@ -150,6 +156,36 @@ def rename(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/copy")
|
||||||
|
def copy(
|
||||||
|
body: TransferBody,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
result = _guard(file_service.copy, body.src, body.dest_dir, body.overwrite)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="file.copy",
|
||||||
|
target=body.src, detail=f"-> {result['path']}", ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/move")
|
||||||
|
def move(
|
||||||
|
body: TransferBody,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
result = _guard(file_service.move, body.src, body.dest_dir, body.overwrite)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action="file.move",
|
||||||
|
target=body.src, detail=f"-> {result['path']}", ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("")
|
@router.delete("")
|
||||||
def delete(
|
def delete(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -171,11 +207,14 @@ async def upload(
|
|||||||
request: Request,
|
request: Request,
|
||||||
path: str = Form(...),
|
path: str = Form(...),
|
||||||
overwrite: bool = Form(False),
|
overwrite: bool = Form(False),
|
||||||
|
rel_path: str = Form(""),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
real = _guard(file_service.upload_target, path, file.filename or "", overwrite)
|
real = _guard(
|
||||||
|
file_service.upload_target, path, file.filename or "", overwrite, rel_path or None
|
||||||
|
)
|
||||||
# Stream to a temp file first, then move into place atomically.
|
# Stream to a temp file first, then move into place atomically.
|
||||||
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
|
tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real))
|
||||||
try:
|
try:
|
||||||
@@ -189,6 +228,6 @@ async def upload(
|
|||||||
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
|
raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, user=user.username, action="file.upload",
|
session, user=user.username, action="file.upload",
|
||||||
target=path, detail=file.filename, ip=_ip(request),
|
target=path, detail=rel_path or file.filename, ip=_ip(request),
|
||||||
)
|
)
|
||||||
return {"ok": True, "name": file.filename}
|
return {"ok": True, "name": rel_path or file.filename}
|
||||||
|
|||||||
@@ -175,14 +175,102 @@ def resolve_download(path: str) -> tuple[str, str]:
|
|||||||
return real, os.path.basename(path)
|
return real, os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
def upload_target(dir_path: str, filename: str, overwrite: bool = False) -> str:
|
def upload_target(
|
||||||
"""Validate an upload destination and return the real path to write to."""
|
dir_path: str,
|
||||||
|
filename: str,
|
||||||
|
overwrite: bool = False,
|
||||||
|
rel_path: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Validate an upload destination and return the real path to write to.
|
||||||
|
|
||||||
|
When ``rel_path`` is given (a folder-upload's relative path such as
|
||||||
|
``photos/2024/img.jpg``) the intermediate directories are created under
|
||||||
|
``dir_path`` and the file lands at their leaf. Each path component is
|
||||||
|
validated to block traversal. Otherwise the file lands directly in
|
||||||
|
``dir_path`` under ``filename``.
|
||||||
|
"""
|
||||||
real_dir = _safe_real(dir_path)
|
real_dir = _safe_real(dir_path)
|
||||||
if not os.path.isdir(real_dir):
|
if not os.path.isdir(real_dir):
|
||||||
raise BrowseError(f"Not a directory: {dir_path}")
|
raise BrowseError(f"Not a directory: {dir_path}")
|
||||||
name = os.path.basename(filename or "")
|
|
||||||
child = _child(dir_path, name)
|
components: list[str]
|
||||||
real = _safe_real(child)
|
if rel_path:
|
||||||
|
# Normalise separators, drop empty segments, validate each component.
|
||||||
|
components = [p for p in rel_path.replace("\\", "/").split("/") if p not in ("", ".")]
|
||||||
|
if not components:
|
||||||
|
raise BrowseError("Invalid upload path")
|
||||||
|
else:
|
||||||
|
components = [os.path.basename(filename or "")]
|
||||||
|
|
||||||
|
# Build the logical path one component at a time; _child rejects "..".
|
||||||
|
logical = dir_path
|
||||||
|
for comp in components:
|
||||||
|
logical = _child(logical, comp)
|
||||||
|
real = _safe_real(logical)
|
||||||
|
|
||||||
|
# Create intermediate directories (mkdir -p), staying inside the sandbox.
|
||||||
|
parent = os.path.dirname(real)
|
||||||
|
try:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise BrowseError(f"Permission denied: {dir_path}") from exc
|
||||||
|
|
||||||
if os.path.exists(real) and not overwrite:
|
if os.path.exists(real) and not overwrite:
|
||||||
raise BrowseError(f"Already exists: {name}")
|
raise BrowseError(f"Already exists: {os.path.basename(logical)}")
|
||||||
return real
|
return real
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Copy / move
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _transfer_dest(src: str, dest_dir: str, overwrite: bool) -> tuple[str, str, str]:
|
||||||
|
"""Validate a copy/move and return (src_real, dest_real, dest_logical)."""
|
||||||
|
src_real = _safe_real(src)
|
||||||
|
if not os.path.lexists(src_real):
|
||||||
|
raise BrowseError(f"No such path: {src}")
|
||||||
|
real_dest_dir = _safe_real(dest_dir)
|
||||||
|
if not os.path.isdir(real_dest_dir):
|
||||||
|
raise BrowseError(f"Not a directory: {dest_dir}")
|
||||||
|
|
||||||
|
name = os.path.basename(src.rstrip("/"))
|
||||||
|
dest_logical = _child(dest_dir, name)
|
||||||
|
dest_real = _safe_real(dest_logical)
|
||||||
|
|
||||||
|
# Refuse to copy/move a directory into itself or its own subtree.
|
||||||
|
src_norm = os.path.normpath(src_real)
|
||||||
|
dest_norm = os.path.normpath(dest_real)
|
||||||
|
if dest_norm == src_norm or dest_norm.startswith(src_norm + os.sep):
|
||||||
|
raise BrowseError("Cannot move or copy a folder into itself")
|
||||||
|
if os.path.exists(dest_real) and not overwrite:
|
||||||
|
raise BrowseError(f"Already exists: {name}")
|
||||||
|
return src_real, dest_real, dest_logical
|
||||||
|
|
||||||
|
|
||||||
|
def copy(src: str, dest_dir: str, overwrite: bool = False) -> dict:
|
||||||
|
src_real, dest_real, dest_logical = _transfer_dest(src, dest_dir, overwrite)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(src_real) and not os.path.islink(src_real):
|
||||||
|
if os.path.exists(dest_real):
|
||||||
|
shutil.rmtree(dest_real)
|
||||||
|
shutil.copytree(src_real, dest_real, symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src_real, dest_real, follow_symlinks=False)
|
||||||
|
except OSError as exc:
|
||||||
|
raise BrowseError(f"Could not copy {src}: {exc.strerror or exc}") from exc
|
||||||
|
return {"path": dest_logical}
|
||||||
|
|
||||||
|
|
||||||
|
def move(src: str, dest_dir: str, overwrite: bool = False) -> dict:
|
||||||
|
src_real, dest_real, dest_logical = _transfer_dest(src, dest_dir, overwrite)
|
||||||
|
try:
|
||||||
|
if os.path.exists(dest_real) and overwrite:
|
||||||
|
if os.path.isdir(dest_real) and not os.path.islink(dest_real):
|
||||||
|
shutil.rmtree(dest_real)
|
||||||
|
else:
|
||||||
|
os.remove(dest_real)
|
||||||
|
shutil.move(src_real, dest_real)
|
||||||
|
except OSError as exc:
|
||||||
|
raise BrowseError(f"Could not move {src}: {exc.strerror or exc}") from exc
|
||||||
|
return {"path": dest_logical}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -44,15 +44,26 @@ export const filesApi = {
|
|||||||
remove: (path: string, recursive = false) =>
|
remove: (path: string, recursive = false) =>
|
||||||
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data),
|
api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data),
|
||||||
|
|
||||||
|
copy: (src: string, destDir: string, overwrite = false) =>
|
||||||
|
api
|
||||||
|
.post<{ path: string }>("/api/files/copy", { src, dest_dir: destDir, overwrite })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
move: (src: string, destDir: string, overwrite = false) =>
|
||||||
|
api
|
||||||
|
.post<{ path: string }>("/api/files/move", { src, dest_dir: destDir, overwrite })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
download: async (path: string, filename: string) => {
|
download: async (path: string, filename: string) => {
|
||||||
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
|
const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" });
|
||||||
triggerDownload(res.data as Blob, filename);
|
triggerDownload(res.data as Blob, filename);
|
||||||
},
|
},
|
||||||
|
|
||||||
upload: async (path: string, file: File, overwrite = false) => {
|
upload: async (path: string, file: File, overwrite = false, relPath = "") => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("path", path);
|
form.append("path", path);
|
||||||
form.append("overwrite", String(overwrite));
|
form.append("overwrite", String(overwrite));
|
||||||
|
if (relPath) form.append("rel_path", relPath);
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
|
const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Upload,
|
Upload,
|
||||||
|
FolderUp,
|
||||||
Download,
|
Download,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -17,6 +18,9 @@ import {
|
|||||||
X,
|
X,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Link2,
|
Link2,
|
||||||
|
Copy,
|
||||||
|
Scissors,
|
||||||
|
ClipboardPaste,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Editor from "@monaco-editor/react";
|
import Editor from "@monaco-editor/react";
|
||||||
@@ -43,6 +47,13 @@ function langForName(name: string): string {
|
|||||||
return LANG_BY_EXT[ext] ?? "plaintext";
|
return LANG_BY_EXT[ext] ?? "plaintext";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Clipboard {
|
||||||
|
src: string;
|
||||||
|
name: string;
|
||||||
|
type: "dir" | "file";
|
||||||
|
mode: "copy" | "cut";
|
||||||
|
}
|
||||||
|
|
||||||
function join(path: string, name: string) {
|
function join(path: string, name: string) {
|
||||||
return `${path === "/" ? "" : path}/${name}`;
|
return `${path === "/" ? "" : path}/${name}`;
|
||||||
}
|
}
|
||||||
@@ -67,7 +78,10 @@ export function Files() {
|
|||||||
const [renaming, setRenaming] = useState<HostPathEntry | null>(null);
|
const [renaming, setRenaming] = useState<HostPathEntry | null>(null);
|
||||||
const [deleting, setDeleting] = useState<HostPathEntry | null>(null);
|
const [deleting, setDeleting] = useState<HostPathEntry | null>(null);
|
||||||
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
|
const [newKind, setNewKind] = useState<"dir" | "file" | null>(null);
|
||||||
|
const [clip, setClip] = useState<Clipboard | null>(null);
|
||||||
|
const [pasteConflict, setPasteConflict] = useState(false);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const folderInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useQuery({
|
const { data, isLoading, isFetching, error } = useQuery({
|
||||||
queryKey: ["files", path, showHidden],
|
queryKey: ["files", path, showHidden],
|
||||||
@@ -95,6 +109,54 @@ export function Files() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Folder upload: send each file with its relative path so the backend
|
||||||
|
// recreates the directory structure. Continues past individual failures.
|
||||||
|
const uploadFolder = useMutation({
|
||||||
|
mutationFn: async (files: File[]) => {
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name;
|
||||||
|
try {
|
||||||
|
await filesApi.upload(path, f, true, rel);
|
||||||
|
ok += 1;
|
||||||
|
} catch {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok, failed };
|
||||||
|
},
|
||||||
|
onSuccess: ({ ok, failed }) => {
|
||||||
|
if (failed) toast.warning(`Uploaded ${ok} file(s), ${failed} failed`);
|
||||||
|
else toast.success(`Uploaded ${ok} file(s)`);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
|
onSettled: () => {
|
||||||
|
if (folderInput.current) folderInput.current.value = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const paste = useMutation({
|
||||||
|
mutationFn: (overwrite: boolean) => {
|
||||||
|
const op = clip!.mode === "copy" ? filesApi.copy : filesApi.move;
|
||||||
|
return op(clip!.src, path, overwrite);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(clip!.mode === "copy" ? "Copied" : "Moved");
|
||||||
|
setClip(null);
|
||||||
|
setPasteConflict(false);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
if (apiErrorMessage(e).startsWith("Already exists")) {
|
||||||
|
setPasteConflict(true);
|
||||||
|
} else {
|
||||||
|
toast.error(apiErrorMessage(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
|
mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -141,6 +203,13 @@ export function Files() {
|
|||||||
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
|
<Button onClick={() => fileInput.current?.click()} loading={upload.isPending}>
|
||||||
<Upload className="h-4 w-4" /> Upload
|
<Upload className="h-4 w-4" /> Upload
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => folderInput.current?.click()}
|
||||||
|
loading={uploadFolder.isPending}
|
||||||
|
>
|
||||||
|
<FolderUp className="h-4 w-4" /> Upload folder
|
||||||
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -150,6 +219,18 @@ export function Files() {
|
|||||||
if (f) upload.mutate(f);
|
if (f) upload.mutate(f);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={folderInput}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
// webkitdirectory/directory are non-standard but widely supported.
|
||||||
|
{...({ webkitdirectory: "", directory: "" } as Record<string, string>)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (files.length) uploadFolder.mutate(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +264,28 @@ export function Files() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{clip && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-sky-50 px-3 py-2 text-sm dark:border-slate-700 dark:bg-sky-950/30">
|
||||||
|
{clip.mode === "copy" ? (
|
||||||
|
<Copy className="h-4 w-4 text-sky-500" />
|
||||||
|
) : (
|
||||||
|
<Scissors className="h-4 w-4 text-sky-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-slate-600 dark:text-slate-300">
|
||||||
|
{clip.mode === "copy" ? "Copy" : "Move"} <span className="font-medium">{clip.name}</span> to{" "}
|
||||||
|
<span className="font-mono text-xs">{path}</span>
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button onClick={() => paste.mutate(false)} loading={paste.isPending}>
|
||||||
|
<ClipboardPaste className="h-4 w-4" /> Paste here
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setClip(null)} disabled={paste.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
|
<p className="p-4 text-sm text-red-500">{apiErrorMessage(error)}</p>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
@@ -238,6 +341,24 @@ export function Files() {
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
title="Copy"
|
||||||
|
onClick={() =>
|
||||||
|
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "copy" })
|
||||||
|
}
|
||||||
|
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 text-slate-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Cut (move)"
|
||||||
|
onClick={() =>
|
||||||
|
setClip({ src: join(path, e.name), name: e.name, type: e.type, mode: "cut" })
|
||||||
|
}
|
||||||
|
className="rounded p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Scissors className="h-4 w-4 text-slate-500" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Rename"
|
title="Rename"
|
||||||
onClick={() => setRenaming(e)}
|
onClick={() => setRenaming(e)}
|
||||||
@@ -317,6 +438,17 @@ export function Files() {
|
|||||||
onCancel={() => setDeleting(null)}
|
onCancel={() => setDeleting(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pasteConflict && clip && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`“${clip.name}” already exists here`}
|
||||||
|
message={`Overwrite the existing ${clip.type === "dir" ? "folder" : "file"} at ${path}?`}
|
||||||
|
confirmLabel="Overwrite"
|
||||||
|
danger
|
||||||
|
busy={paste.isPending}
|
||||||
|
onConfirm={() => paste.mutate(true)}
|
||||||
|
onCancel={() => setPasteConflict(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user