This commit is contained in:
kodi
2026-03-11 15:25:32 +01:00
parent 6a1a575383
commit d1f018a130
22 changed files with 909 additions and 49 deletions
+56 -20
View File
@@ -11,14 +11,34 @@ class ResolvedPath:
alias: str
relative: str
absolute: Path
display_style: str
class PathGuard:
def __init__(self, root_aliases: dict[str, str]):
normalized: dict[str, Path] = {}
volume_roots_candidates: dict[str, list[tuple[str, Path]]] = {}
for alias, root in root_aliases.items():
normalized[alias] = Path(root).resolve()
resolved_root = Path(root).resolve()
normalized[alias] = resolved_root
volume_name = resolved_root.name
volume_roots_candidates.setdefault(volume_name, []).append((alias, resolved_root))
self._roots = normalized
self._volume_roots = {
name: entries[0]
for name, entries in volume_roots_candidates.items()
if len(entries) == 1
}
def is_virtual_volumes_path(self, input_path: str) -> bool:
normalized_input = (input_path or "").strip()
return normalized_input == "/Volumes"
def virtual_volumes_entries(self) -> list[dict[str, str]]:
return [
{"name": name, "path": f"/Volumes/{name}"}
for name in sorted(self._volume_roots.keys(), key=str.lower)
]
def resolve_directory_path(self, input_path: str) -> ResolvedPath:
resolved = self.resolve_path(input_path)
@@ -50,7 +70,7 @@ class PathGuard:
return resolved
def resolve_path(self, input_path: str) -> ResolvedPath:
alias, rel_segments, candidate = self.resolve_lexical_path(input_path)
alias, rel_segments, candidate, display_style = self.resolve_lexical_path(input_path)
root = self._roots[alias]
# Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps
@@ -66,12 +86,14 @@ class PathGuard:
return ResolvedPath(
alias=alias,
relative=self._format_relative(alias, rel_segments),
relative=self._format_relative(alias, rel_segments, display_style),
absolute=resolved_candidate,
display_style=display_style,
)
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]:
normalized_input = (input_path or "").strip().strip("/")
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path, str]:
raw_input = (input_path or "").strip()
normalized_input = raw_input.strip("/")
if not normalized_input:
raise AppError(
code="invalid_request",
@@ -80,16 +102,28 @@ class PathGuard:
)
segments = [seg for seg in normalized_input.split("/") if seg]
alias = segments[0] if segments else ""
if alias not in self._roots:
raise AppError(
code="invalid_root_alias",
message="Unknown root alias",
status_code=403,
details={"path": input_path},
)
display_style = "alias"
alias = ""
rel_segments: list[str] = []
root: Path
if len(segments) >= 2 and segments[0] == "Volumes" and segments[1] in self._volume_roots:
display_style = "virtual_volumes"
volume_name = segments[1]
alias, root = self._volume_roots[volume_name]
rel_segments = segments[2:]
else:
alias = segments[0] if segments else ""
if alias not in self._roots:
raise AppError(
code="invalid_root_alias",
message="Unknown root alias",
status_code=403,
details={"path": input_path},
)
root = self._roots[alias]
rel_segments = segments[1:]
rel_segments = segments[1:]
if any(seg == ".." for seg in rel_segments):
raise AppError(
code="path_traversal_detected",
@@ -98,9 +132,8 @@ class PathGuard:
details={"path": input_path},
)
root = self._roots[alias]
candidate = root.joinpath(*rel_segments)
return alias, rel_segments, candidate
return alias, rel_segments, candidate, display_style
def validate_name(self, name: str, field: str) -> str:
normalized = (name or "").strip()
@@ -113,7 +146,7 @@ class PathGuard:
)
return normalized
def entry_relative_path(self, alias: str, absolute: Path) -> str:
def entry_relative_path(self, alias: str, absolute: Path, display_style: str = "alias") -> str:
root = self._roots[alias]
resolved_absolute = absolute.resolve(strict=False)
if not self._is_under_root(resolved_absolute, root):
@@ -124,7 +157,7 @@ class PathGuard:
details={"path": f"{alias}"},
)
rel = resolved_absolute.relative_to(root).as_posix()
return self._format_relative(alias, [p for p in rel.split("/") if p])
return self._format_relative(alias, [p for p in rel.split("/") if p], display_style)
@staticmethod
def _is_under_root(path: Path, root: Path) -> bool:
@@ -134,6 +167,9 @@ class PathGuard:
except ValueError:
return False
@staticmethod
def _format_relative(alias: str, rel_segments: list[str]) -> str:
def _format_relative(self, alias: str, rel_segments: list[str], display_style: str = "alias") -> str:
if display_style == "virtual_volumes":
root = self._roots[alias]
prefix = f"/Volumes/{root.name}"
return prefix if not rel_segments else f"{prefix}/{'/'.join(rel_segments)}"
return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}"
+13 -2
View File
@@ -11,13 +11,22 @@ class BrowseService:
self._filesystem = filesystem
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
if self._path_guard.is_virtual_volumes_path(path):
directories = [
DirectoryEntry(name=item["name"], path=item["path"], modified="")
for item in self._path_guard.virtual_volumes_entries()
]
return BrowseResponse(path="/Volumes", directories=directories, files=[])
resolved = self._path_guard.resolve_directory_path(path)
directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden)
directories = [
DirectoryEntry(
name=item["name"],
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
path=self._path_guard.entry_relative_path(
resolved.alias, item["absolute"], display_style=resolved.display_style
),
modified=item["modified"],
)
for item in directories_raw
@@ -26,7 +35,9 @@ class BrowseService:
files = [
FileEntry(
name=item["name"],
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
path=self._path_guard.entry_relative_path(
resolved.alias, item["absolute"], display_style=resolved.display_style
),
size=item["size"],
modified=item["modified"],
)
@@ -17,7 +17,7 @@ class CopyTaskService:
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
resolved_source = self._path_guard.resolve_existing_path(source)
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
if lexical_source.is_symlink():
raise AppError(
code="type_conflict",
@@ -36,7 +36,11 @@ class CopyTaskService:
resolved_destination = self._path_guard.resolve_path(destination)
destination_parent = resolved_destination.absolute.parent
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
parent_relative = self._path_guard.entry_relative_path(
resolved_destination.alias,
destination_parent,
display_style=resolved_destination.display_style,
)
self._map_directory_validation(parent_relative)
if resolved_destination.absolute.exists():
@@ -68,7 +68,11 @@ class FileOpsService:
resolved_source = self._path_guard.resolve_existing_path(path)
safe_name = self._path_guard.validate_name(new_name, field="new_name")
parent_relative = self._path_guard.entry_relative_path(resolved_source.alias, resolved_source.absolute.parent)
parent_relative = self._path_guard.entry_relative_path(
resolved_source.alias,
resolved_source.absolute.parent,
display_style=resolved_source.display_style,
)
target_relative = self._join_relative(parent_relative, safe_name)
resolved_target = self._path_guard.resolve_path(target_relative)
@@ -15,7 +15,7 @@ class MoveTaskService:
def create_move_task(self, source: str, destination: str) -> TaskCreateResponse:
resolved_source = self._path_guard.resolve_existing_path(source)
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
if lexical_source.is_symlink():
raise AppError(
@@ -34,7 +34,11 @@ class MoveTaskService:
resolved_destination = self._path_guard.resolve_path(destination)
destination_parent = resolved_destination.absolute.parent
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
parent_relative = self._path_guard.entry_relative_path(
resolved_destination.alias,
destination_parent,
display_style=resolved_destination.display_style,
)
self._map_directory_validation(parent_relative)
if resolved_destination.absolute.exists():
Binary file not shown.
@@ -21,13 +21,21 @@ from backend.app.services.browse_service import BrowseService
class BrowseApiGoldenTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name) / "root"
self.volumes_root = Path(self.temp_dir.name) / "Volumes"
self.volumes_root.mkdir(parents=True, exist_ok=True)
self.root = self.volumes_root / "8TB"
self.root.mkdir(parents=True, exist_ok=True)
self.second_root = self.volumes_root / "8TB_RAID1"
self.second_root.mkdir(parents=True, exist_ok=True)
self.unconfigured_root = self.volumes_root / "Other"
self.unconfigured_root.mkdir(parents=True, exist_ok=True)
folder = self.root / "folder"
folder.mkdir()
file_path = self.root / "video.mkv"
file_path.write_bytes(b"abc")
second_file = self.second_root / "archive.txt"
second_file.write_text("z", encoding="utf-8")
hidden_dir = self.root / ".hidden_dir"
hidden_dir.mkdir()
@@ -35,14 +43,14 @@ class BrowseApiGoldenTest(unittest.TestCase):
hidden_file.write_bytes(b"x")
mtime = 1710000000
for path in [folder, file_path, hidden_dir, hidden_file]:
for path in [folder, file_path, hidden_dir, hidden_file, second_file]:
Path(path).touch()
Path(path).chmod(0o755)
import os
os.utime(path, (mtime, mtime))
service = BrowseService(
path_guard=PathGuard({"storage1": str(self.root)}),
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
filesystem=FilesystemAdapter(),
)
async def _override_browse_service() -> BrowseService:
@@ -100,6 +108,49 @@ class BrowseApiGoldenTest(unittest.TestCase):
self.assertEqual(directory_names, [".hidden_dir", "folder"])
self.assertEqual(file_names, [".secret", "video.mkv"])
def test_browse_virtual_volumes_lists_only_configured_mounts(self) -> None:
response = self._get("/Volumes")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json(),
{
"path": "/Volumes",
"directories": [
{"name": "8TB", "path": "/Volumes/8TB", "modified": ""},
{"name": "8TB_RAID1", "path": "/Volumes/8TB_RAID1", "modified": ""},
],
"files": [],
},
)
def test_browse_virtual_mount_maps_to_configured_root(self) -> None:
response = self._get("/Volumes/8TB")
self.assertEqual(response.status_code, 200)
modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
self.assertEqual(
response.json(),
{
"path": "/Volumes/8TB",
"directories": [
{
"name": "folder",
"path": "/Volumes/8TB/folder",
"modified": modified,
}
],
"files": [
{
"name": "video.mkv",
"path": "/Volumes/8TB/video.mkv",
"size": 3,
"modified": modified,
}
],
},
)
if __name__ == "__main__":
unittest.main()
@@ -41,13 +41,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("F6", body)
self.assertIn("F7", body)
self.assertIn("F8", body)
self.assertIn("Alt+R", body)
self.assertIn('id="viewer-modal"', body)
self.assertIn('id="viewer-content"', body)
self.assertIn('id="editor-modal"', body)
self.assertIn('id="editor-content"', body)
self.assertIn('id="editor-save-btn"', body)
self.assertIn('id="editor-cancel-btn"', body)
self.assertIn('id="rename-move-popup"', body)
self.assertIn('id="rename-move-input"', body)
self.assertIn('id="batch-move-popup"', body)
self.assertIn('id="batch-move-apply-btn"', body)
self.assertIn('id="mkdir-btn"', body)
self.assertIn('id="copy-btn"', body)
self.assertIn('id="move-btn"', body)
@@ -77,6 +80,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
static_root = Path(mount.app.directory)
self.assertTrue((static_root / "app.js").exists())
self.assertTrue((static_root / "style.css").exists())
app_js = (static_root / "app.js").read_text(encoding="utf-8")
self.assertIn('currentPath: "/Volumes"', app_js)
app_js_url = app.url_path_for("ui", path="/app.js")
style_css_url = app.url_path_for("ui", path="/style.css")
+19
View File
@@ -11,6 +11,25 @@ from backend.app.config import get_settings
class ConfigTest(unittest.TestCase):
def test_default_root_aliases_include_storage1_and_storage2(self) -> None:
original = os.environ.get("WEBMANAGER_ROOT_ALIASES")
try:
os.environ.pop("WEBMANAGER_ROOT_ALIASES", None)
settings = get_settings()
finally:
if original is None:
os.environ.pop("WEBMANAGER_ROOT_ALIASES", None)
else:
os.environ["WEBMANAGER_ROOT_ALIASES"] = original
self.assertEqual(
settings.root_aliases,
{
"storage1": "/Volumes/8TB",
"storage2": "/Volumes/8TB_RAID1",
},
)
def test_default_task_db_path_is_backend_data_absolute(self) -> None:
original = os.environ.get("WEBMANAGER_TASK_DB_PATH")
try:
+300 -17
View File
@@ -1,7 +1,7 @@
let state = {
panes: {
left: {
currentPath: "storage1",
currentPath: "/Volumes",
showHidden: false,
selectedItem: null,
selectedItems: [],
@@ -9,7 +9,7 @@ let state = {
currentRowIndex: -1,
},
right: {
currentPath: "storage1",
currentPath: "/Volumes",
showHidden: false,
selectedItem: null,
selectedItems: [],
@@ -28,6 +28,14 @@ let editorState = {
originalContent: "",
modified: null,
};
let renameMoveState = {
source: null,
destination: "",
};
let batchMoveState = {
destinationBase: "",
count: 0,
};
function paneState(pane) {
return state.panes[pane];
@@ -89,6 +97,28 @@ function editorElements() {
};
}
function renameMoveElements() {
return {
overlay: document.getElementById("rename-move-popup"),
source: document.getElementById("rename-move-source"),
input: document.getElementById("rename-move-input"),
error: document.getElementById("rename-move-error"),
applyButton: document.getElementById("rename-move-apply-btn"),
cancelButton: document.getElementById("rename-move-cancel-btn"),
};
}
function batchMoveElements() {
return {
overlay: document.getElementById("batch-move-popup"),
count: document.getElementById("batch-move-count"),
destination: document.getElementById("batch-move-destination"),
error: document.getElementById("batch-move-error"),
applyButton: document.getElementById("batch-move-apply-btn"),
cancelButton: document.getElementById("batch-move-cancel-btn"),
};
}
async function apiRequest(method, url, body) {
const options = { method, headers: {} };
if (body !== undefined) {
@@ -181,7 +211,7 @@ function updateActionButtons() {
document.getElementById("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !allFiles;
document.getElementById("move-btn").disabled = !allFiles;
document.getElementById("move-btn").disabled = !hasSelection;
}
function isEditableSelection(item) {
@@ -197,23 +227,66 @@ function isEditableSelection(item) {
}
function currentParentPath(path) {
if (!path.includes("/")) {
const normalized = (path || "").trim();
if (!normalized) {
return null;
}
const segments = path.split("/");
if (normalized === "/Volumes") {
return null;
}
if (normalized.startsWith("/")) {
const segments = normalized.split("/").filter(Boolean);
if (segments.length <= 1) {
return null;
}
if (segments.length === 2) {
return `/${segments[0]}`;
}
return `/${segments.slice(0, -1).join("/")}`;
}
if (!normalized.includes("/")) {
return null;
}
const segments = normalized.split("/");
if (segments.length === 2) {
return segments[0];
}
return segments.slice(0, -1).join("/");
}
function baseName(path) {
const index = path.lastIndexOf("/");
return index >= 0 ? path.slice(index + 1) : path;
}
function renderBreadcrumbs(pane, path) {
const nav = document.getElementById(`${pane}-breadcrumbs`);
nav.innerHTML = "";
const parts = path.split("/");
let aggregate = "";
const normalized = (path || "").trim();
const isHostPath = normalized.startsWith("/");
const parts = normalized.split("/").filter(Boolean);
if (isHostPath) {
const rootCrumb = createButton("/", () => {
setActivePane(pane);
navigateTo(pane, "/Volumes");
});
rootCrumb.type = "button";
rootCrumb.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
setActivePane(pane);
navigateTo(pane, "/Volumes");
};
nav.append(rootCrumb);
if (parts.length > 0) {
const sep = document.createElement("span");
sep.textContent = "/";
nav.append(sep);
}
}
let aggregate = isHostPath ? "" : "";
for (let i = 0; i < parts.length; i += 1) {
aggregate = i === 0 ? parts[i] : `${aggregate}/${parts[i]}`;
aggregate = isHostPath ? `/${parts.slice(0, i + 1).join("/")}` : (i === 0 ? parts[i] : `${aggregate}/${parts[i]}`);
const crumbPath = aggregate;
const crumb = createButton(parts[i], () => {
setActivePane(pane);
@@ -552,7 +625,7 @@ async function deleteSelected() {
}
function defaultDestination(sourcePath, targetBasePath) {
const sourceName = sourcePath.slice(sourcePath.lastIndexOf("/") + 1);
const sourceName = baseName(sourcePath);
return `${targetBasePath}/${sourceName}`;
}
@@ -593,13 +666,15 @@ async function startCopySelected() {
}
async function startMoveSelected() {
await executeMoveSelection(paneState(otherPane(state.activePane)).currentPath);
}
async function executeMoveSelection(baseDestination) {
const sourcePane = state.activePane;
const destinationPane = otherPane(sourcePane);
const selectedItems = [...paneState(sourcePane).selectedItems];
if (selectedItems.length === 0) {
return;
}
const baseDestination = paneState(destinationPane).currentPath;
setError("actions-error", "");
let successes = 0;
let failures = 0;
@@ -690,7 +765,7 @@ function actionShortcutHandled(event) {
return triggerActionButton("copy-btn");
}
if (event.key === "F6") {
return triggerActionButton("move-btn");
return openF6Flow();
}
if (event.key === "F7") {
return triggerActionButton("mkdir-btn");
@@ -712,7 +787,7 @@ function actionShortcutHandled(event) {
return triggerActionButton("copy-btn");
}
if (key === "6") {
return triggerActionButton("move-btn");
return openF6Flow();
}
if (key === "7") {
return triggerActionButton("mkdir-btn");
@@ -720,9 +795,6 @@ function actionShortcutHandled(event) {
if (key === "8") {
return triggerActionButton("delete-btn");
}
if (key === "r") {
return triggerActionButton("rename-btn");
}
}
return false;
@@ -744,6 +816,14 @@ function isWildcardPopupOpen() {
return !wildcardPopupElements().overlay.classList.contains("hidden");
}
function isRenameMovePopupOpen() {
return !renameMoveElements().overlay.classList.contains("hidden");
}
function isBatchMovePopupOpen() {
return !batchMoveElements().overlay.classList.contains("hidden");
}
function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden");
}
@@ -806,6 +886,155 @@ function closeWildcardPopup() {
elements.input.value = "";
}
function showDirectoryMoveNotSupported() {
const message = "Directory move is not supported in v1";
setError("actions-error", message);
setStatus(message);
}
function resetRenameMoveState() {
renameMoveState = {
source: null,
destination: "",
};
}
function resetBatchMoveState() {
batchMoveState = {
destinationBase: "",
count: 0,
};
}
function closeRenameMovePopup() {
const elements = renameMoveElements();
elements.overlay.classList.add("hidden");
elements.error.textContent = "";
elements.input.value = "";
resetRenameMoveState();
}
function closeBatchMovePopup() {
const elements = batchMoveElements();
elements.overlay.classList.add("hidden");
elements.error.textContent = "";
resetBatchMoveState();
}
function openRenameMovePopup() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length !== 1) {
return false;
}
const source = selectedItems[0];
const destination = defaultDestination(source.path, paneState(otherPane(state.activePane)).currentPath);
const elements = renameMoveElements();
renameMoveState.source = source;
renameMoveState.destination = destination;
elements.source.textContent = `Source: ${source.path}`;
elements.input.value = destination;
elements.error.textContent = "";
elements.overlay.classList.remove("hidden");
elements.input.focus();
elements.input.select();
return true;
}
function openBatchMovePopup(selectedItems) {
if (selectedItems.length === 0) {
return false;
}
const destinationBase = paneState(otherPane(state.activePane)).currentPath;
const elements = batchMoveElements();
batchMoveState.destinationBase = destinationBase;
batchMoveState.count = selectedItems.length;
elements.count.textContent = `${selectedItems.length} selected item(s)`;
elements.destination.textContent = `Destination: ${destinationBase}`;
elements.error.textContent = "";
elements.overlay.classList.remove("hidden");
elements.applyButton.focus();
return true;
}
function openF6Flow() {
const selectedItems = activePaneState().selectedItems;
if (selectedItems.length === 0) {
return false;
}
if (selectedItems.length === 1) {
return openRenameMovePopup();
}
if (selectedItems.some((item) => item.kind !== "file")) {
showDirectoryMoveNotSupported();
return true;
}
return openBatchMovePopup(selectedItems);
}
async function submitRenameMovePopup() {
const elements = renameMoveElements();
const source = renameMoveState.source;
if (!source) {
return;
}
const destination = elements.input.value.trim();
const sourceParent = currentParentPath(source.path);
const destinationParent = currentParentPath(destination);
const destinationName = baseName(destination);
elements.error.textContent = "";
if (!destination) {
elements.error.textContent = "Destination path is required";
return;
}
if (destination === source.path) {
elements.error.textContent = "Destination must differ from source";
return;
}
if (source.kind === "directory" && destinationParent !== sourceParent) {
elements.error.textContent = "Directory move is not supported in v1";
return;
}
try {
if (destinationParent === sourceParent) {
await apiRequest("POST", "/api/files/rename", {
path: source.path,
new_name: destinationName,
});
closeRenameMovePopup();
setSelectedItem(state.activePane, null);
await loadBrowsePane(state.activePane);
setStatus(`Renamed ${source.path}`);
return;
}
const result = await apiRequest("POST", "/api/files/move", {
source: source.path,
destination,
});
state.selectedTaskId = result.task_id;
await refreshTasksSnapshot();
closeRenameMovePopup();
setSelectedItem(state.activePane, null);
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
setStatus(`Move: 1 success, 0 failed`);
} catch (err) {
elements.error.textContent = err.message;
}
}
async function submitBatchMovePopup() {
const elements = batchMoveElements();
elements.error.textContent = "";
try {
await executeMoveSelection(batchMoveState.destinationBase);
closeBatchMovePopup();
} catch (err) {
elements.error.textContent = err.message;
}
}
function submitWildcardPopup() {
const elements = wildcardPopupElements();
const pattern = elements.input.value.trim();
@@ -1003,6 +1232,31 @@ function clearSelectionForActivePane() {
}
function handleKeyboardShortcuts(event) {
if (isBatchMovePopupOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeBatchMovePopup();
return;
}
if (event.key === "Enter") {
event.preventDefault();
submitBatchMovePopup();
return;
}
return;
}
if (isRenameMovePopupOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeRenameMovePopup();
return;
}
if (event.key === "Enter") {
event.preventDefault();
submitRenameMovePopup();
}
return;
}
if (isEditorOpen()) {
if (event.key === "Escape") {
event.preventDefault();
@@ -1108,7 +1362,7 @@ function setupEvents() {
document.getElementById("rename-btn").onclick = renameSelected;
document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected;
document.getElementById("move-btn").onclick = startMoveSelected;
document.getElementById("move-btn").onclick = openF6Flow;
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
const wildcard = wildcardPopupElements();
@@ -1131,6 +1385,35 @@ function setupEvents() {
}
};
const renameMove = renameMoveElements();
renameMove.cancelButton.onclick = closeRenameMovePopup;
renameMove.applyButton.onclick = submitRenameMovePopup;
renameMove.input.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
submitRenameMovePopup();
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeRenameMovePopup();
}
};
renameMove.overlay.onclick = (event) => {
if (event.target === renameMove.overlay) {
closeRenameMovePopup();
}
};
const batchMove = batchMoveElements();
batchMove.cancelButton.onclick = closeBatchMovePopup;
batchMove.applyButton.onclick = submitBatchMovePopup;
batchMove.overlay.onclick = (event) => {
if (event.target === batchMove.overlay) {
closeBatchMovePopup();
}
};
const viewer = viewerElements();
viewer.closeButton.onclick = closeViewer;
viewer.overlay.onclick = (event) => {
+28 -1
View File
@@ -66,7 +66,7 @@
<button id="edit-btn" type="button" disabled><span class="shortcut-hint">F4</span><span>Edit</span></button>
<button id="copy-btn" type="button" disabled><span class="shortcut-hint">F5</span><span>Copy</span></button>
<button id="move-btn" type="button" disabled><span class="shortcut-hint">F6</span><span>Move</span></button>
<button id="rename-btn" type="button" disabled><span class="shortcut-hint">Alt+R</span><span>Rename</span></button>
<button id="rename-btn" type="button" disabled><span>Rename</span></button>
<button id="mkdir-btn" type="button"><span class="shortcut-hint">F7</span><span>MKdir</span></button>
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
</div>
@@ -88,6 +88,33 @@
</div>
</div>
<div id="rename-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="rename-move-title">
<div class="popup-card">
<h3 id="rename-move-title">Rename/Move</h3>
<div id="rename-move-source" class="popup-meta"></div>
<label for="rename-move-input" class="popup-label">Destination path</label>
<input id="rename-move-input" type="text" autocomplete="off">
<div id="rename-move-error" class="error"></div>
<div class="popup-actions">
<button id="rename-move-apply-btn" type="button">OK</button>
<button id="rename-move-cancel-btn" type="button">Cancel</button>
</div>
</div>
</div>
<div id="batch-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="batch-move-title">
<div class="popup-card">
<h3 id="batch-move-title">Batch Move</h3>
<div id="batch-move-count" class="popup-meta"></div>
<div id="batch-move-destination" class="popup-meta"></div>
<div id="batch-move-error" class="error"></div>
<div class="popup-actions">
<button id="batch-move-apply-btn" type="button">Move</button>
<button id="batch-move-cancel-btn" type="button">Cancel</button>
</div>
</div>
</div>
<div id="viewer-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="viewer-title">
<div class="popup-card viewer-card">
<button id="viewer-close-btn" class="viewer-close" type="button" aria-label="Close viewer">X</button>
+6
View File
@@ -372,6 +372,12 @@ button:disabled {
margin-bottom: 6px;
}
#rename-move-input {
width: 100%;
margin-top: 4px;
margin-bottom: 6px;
}
.popup-actions {
display: flex;
gap: 8px;