Дезайн ыыыы
This commit is contained in:
parent
403d7086d4
commit
e7c02c7feb
174 changed files with 15802 additions and 9725 deletions
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"hash": "26e02935",
|
||||
"configHash": "f7e27824",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "cf5a5f58",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
5375
Cargo.lock
generated
5375
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
# LoFlum
|
||||
# Wherry
|
||||
|
||||

|
||||

|
||||
|
|
|
|||
181
Wherry New Design (standalone).html
Normal file
181
Wherry New Design (standalone).html
Normal file
File diff suppressed because one or more lines are too long
79
docs/TAURI_BACKEND_API.md
Normal file
79
docs/TAURI_BACKEND_API.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Wherry Tauri backend API (design)
|
||||
|
||||
Goal: keep everything under `src/domain`, `src/fs`, `src/protocols`, `src/storage`, `src/transfer` as the Tauri backend. All of it already speaks in `serde` (camelCase) structs, so this is mostly a thin `#[tauri::command]` wrapper layer plus an event bus to replace the current per-frame egui polling.
|
||||
|
||||
State the current egui frontend polls every frame that the new frontend instead gets pushed to it via Tauri events (see "Events" below): `state.queue_tasks` (`TransferQueue::all()`), `state.tabs[].status` (`ConnectionStatus`).
|
||||
|
||||
## Managed state (`tauri::State`)
|
||||
|
||||
Same objects `lib.rs::run()` already builds, handed to Tauri via `.manage(...)` instead of into `FileManagerApp`:
|
||||
|
||||
- `Arc<Mutex<rusqlite::Connection>>` — sqlite db (sites/bookmarks/history/settings)
|
||||
- `Arc<RemoteRegistry>` — live `connection_id -> Arc<dyn RemoteFs>` map
|
||||
- `TransferQueue` — shared transfer task list
|
||||
- `Arc<AtomicU32>` — max_concurrent, mutated live by Settings → Transfers
|
||||
|
||||
`transfer::worker::spawn_worker` keeps running as-is inside `tauri::Builder::setup`, using the app handle to `emit` progress instead of the queue being polled.
|
||||
|
||||
## Commands
|
||||
|
||||
### Connections (`src/protocols`, `src/fs/remote.rs`)
|
||||
|
||||
- `connect(params: ConnectionParams) -> Result<String, String>` — dispatches to `FtpClient::connect` / `SftpClient::connect_password|connect_key|connect_auto` based on `params.protocol`/`password`/`key_path`; on success `RemoteRegistry::insert(id, fs)`, `db::add_history_entry(...)`, returns `connection_id`. Emits `connection-status` (`Connecting` then `Connected`/`Error`) — see Events.
|
||||
- `disconnect(connection_id: String)` — `RemoteRegistry::remove`.
|
||||
|
||||
### Remote file ops (`RemoteFs` trait, per `connection_id`)
|
||||
|
||||
- `remote_list(connection_id: String, path: String) -> Result<Vec<FileEntry>, String>`
|
||||
- `remote_stat(connection_id: String, path: String) -> Result<FileEntry, String>`
|
||||
- `remote_mkdir(connection_id: String, path: String) -> Result<(), String>`
|
||||
- `remote_rename(connection_id: String, from: String, to: String) -> Result<(), String>`
|
||||
- `remote_delete(connection_id: String, path: String) -> Result<(), String>`
|
||||
|
||||
All resolve `Arc<dyn RemoteFs>` from `RemoteRegistry::get`, return `"connection not found"` error if missing (mirrors `worker.rs` behavior).
|
||||
|
||||
### Local file ops (`src/fs/local.rs` — already sync, no connection_id)
|
||||
|
||||
- `local_list(path: String) -> Result<Vec<FileEntry>, String>`
|
||||
- `local_home_dir() -> String`
|
||||
- `local_mkdir(path: String) -> Result<(), String>`
|
||||
- `local_rename(from: String, to: String) -> Result<(), String>`
|
||||
- `local_delete(path: String) -> Result<(), String>`
|
||||
- `local_open(path: String) -> Result<(), String>` — reveal/open in system app
|
||||
- `local_move_into(src_path: String, dest_dir: String) -> Result<(), String>` — drag&drop move
|
||||
|
||||
### Transfers (`src/transfer`)
|
||||
|
||||
- `enqueue_transfer(kind: TransferKind, connection_id: String, local_path: String, remote_path: String, file_name: String, total_bytes: u64) -> String` — builds `TransferTask::new(...)`, `queue.push(task)`, returns task id. Worker picks it up same as today.
|
||||
- `list_tasks() -> Vec<TransferTask>` — for initial load / reconnect after frontend refresh; live updates come from events, not polling.
|
||||
- `pause_task(id: String)` / `resume_task(id: String)` — `queue.update_state(id, TaskState::Paused/Queued)` (worker's progress callback already checks for `Paused`/`Cancelled` each callback tick).
|
||||
- `cancel_task(id: String)` — `queue.update_state(id, TaskState::Cancelled)`.
|
||||
- `remove_task(id: String)` — `queue.remove(id)` (for clearing completed/failed rows).
|
||||
- `set_max_concurrent(n: u32)` — stores atomically + persists via `db::set_u32`.
|
||||
|
||||
### Sites / bookmarks / history / settings (`src/storage/db`)
|
||||
|
||||
- `list_sites`, `save_site(site: Site)`, `delete_site(id: String)`
|
||||
- `list_bookmarks() -> Vec<(i64, String, String)>` (id, name, path), `add_bookmark(name, path) -> i64`, `remove_bookmark(id: i64)`
|
||||
- `list_history() -> Vec<HistoryRow>`, `clear_history()`, `find_history_conn_id(host, port, username) -> Option<String>`
|
||||
- `get_pref_bool/str/u32(key)`, `set_pref_bool/str/u32(key, value)` — thin wrapper over `storage::db::settings`, used for confirm-before-delete, default local folder, language, transfer concurrency, panel visibility persistence if we decide to persist that (currently session-only in egui, per `state/mod.rs` comment) — confirm with FRONTEND_SPEC.md once written.
|
||||
|
||||
Credentials: `keyring` crate stays as-is; password storage keyed by `ConnectionParams.id` (stable id from history/sites), unchanged from current behavior — no new command needed beyond what `connect` already resolves internally.
|
||||
|
||||
## Events (app -> frontend, `app_handle.emit(...)`)
|
||||
|
||||
Replaces per-frame polling of `queue.all()` / `tabs[].status` in the egui loop:
|
||||
|
||||
- `transfer-progress` payload `{ id, transferredBytes, speed, etaSecs }` — emitted from the same throttled callback in `worker.rs::run_transfer` (already rate-limited to ~10/s via `ProgressThrottle`), just add `app_handle.emit(...)` next to `queue.update_progress(...)`.
|
||||
- `transfer-state-changed` payload `{ id, state: TaskState }` — emitted next to every `queue.update_state(...)` call (queued->running->completed/failed/cancelled/paused).
|
||||
- `connection-status-changed` payload `{ connectionId, status: ConnectionStatus }` — emitted from `connect`/`disconnect` and on unexpected drop (if/when we add liveness detection — not present today, egui doesn't detect mid-session drops either, so parity-only for v1).
|
||||
|
||||
Frontend subscribes once via `@tauri-apps/api/event.listen`, keeps a local task/connection map updated from these — no polling loop needed like `queue.all()` was in egui.
|
||||
|
||||
## Serialization note
|
||||
|
||||
Every domain type already derives `Serialize`/`Deserialize` with `#[serde(rename_all = "camelCase")]` (`ConnectionParams`, `FileEntry`, `Site`, `TransferTask`, `TaskState`, `Protocol`, `EntryKind`, `ConnectionStatus`) — these cross the Tauri IPC boundary unchanged, no adapter layer needed. `anyhow::Error` doesn't implement `Serialize`, so every command returns `Result<T, String>` (`.map_err(|e| e.to_string())`), matching the existing convention of stringly-typed errors already used in `TaskState::Failed(String)`.
|
||||
|
||||
## Open question for scaffolding phase (task #3)
|
||||
|
||||
Whether to keep `src/` as a single crate with a new `src-tauri/` binary depending on it (`wherry_lib` is already `staticlib/cdylib/rlib`, so this fits cleanly), or fold `src-tauri` in place of `main.rs`/`lib.rs::run()`. Recommend the former — cleanest separation, no changes to `domain`/`fs`/`protocols`/`storage`/`transfer` needed at all, only `lib.rs::run()` and `main.rs` get replaced by Tauri's builder.
|
||||
859
frontend/css/components.css
Normal file
859
frontend/css/components.css
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
/* Reusable widgets — every interactive control lives here so nothing in a
|
||||
screen-level stylesheet ever hand-rolls its own button/row look. */
|
||||
|
||||
/* ---------- icon ---------- */
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* The duotone "background" layer — each tier gets its own semi-transparent
|
||||
soft fill so the two-tone effect is visible no matter the icon color.
|
||||
Using color-mix() instead of real alpha avoids muddy compositing on
|
||||
colorful backgrounds (see icons.ts). */
|
||||
.icon {
|
||||
--icon-soft: color-mix(in srgb, var(--text-primary) 30%, transparent);
|
||||
}
|
||||
.icon .icon-soft {
|
||||
fill: var(--icon-soft);
|
||||
}
|
||||
|
||||
/* ---- Icon color tiers ----
|
||||
Unselected / not important → white (default)
|
||||
More important → orange (--accent)
|
||||
Inactive / other → gray (--text-dim)
|
||||
On orange button (CTA) → accent-color soft layer
|
||||
*/
|
||||
.icon { color: inherit; }
|
||||
.icon--important {
|
||||
color: var(--accent);
|
||||
--icon-soft: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
.icon--inactive {
|
||||
color: var(--text-dim);
|
||||
--icon-soft: color-mix(in srgb, var(--text-dim) 30%, transparent);
|
||||
}
|
||||
.icon--on-accent-bg {
|
||||
color: var(--accent);
|
||||
--icon-soft: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 12px;
|
||||
height: 32px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
background-color 0.1s var(--ease-out),
|
||||
color 0.1s var(--ease-out),
|
||||
transform 0.06s var(--ease-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-ghost:not(:disabled):active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-accent:not(:disabled):hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-accent:not(:disabled):active {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.btn-accent-dim {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-accent-dim:not(:disabled):hover {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--red);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--red-hover);
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.btn-toggle:not(.active):not(:disabled):hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------- clickable rows (sidebar / recent-connections list) ---------- */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1px 10px;
|
||||
padding: 0 10px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.1s var(--ease-out);
|
||||
}
|
||||
.row:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
.row:active {
|
||||
background: var(--bg-row-sel);
|
||||
}
|
||||
.row.active {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.row .row-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- spinner ---------- */
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid color-mix(in srgb, var(--text-primary) 18%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- keyboard shortcut chip ---------- */
|
||||
.kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kbd-on-accent {
|
||||
color: var(--on-accent);
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ---------- protocol badge (saved sites / history rows) ---------- */
|
||||
.proto-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
/* ---------- status dot ---------- */
|
||||
.status-dot {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
.status-dot.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
.status-dot.connecting {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.status-dot.error {
|
||||
color: var(--red);
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
color: var(--text-hint);
|
||||
}
|
||||
|
||||
/* ---------- form fields ---------- */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.field-input {
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-content);
|
||||
color: var(--text-primary);
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.1s var(--ease-out), box-shadow 0.1s var(--ease-out);
|
||||
width: 100%;
|
||||
}
|
||||
.field-input::placeholder {
|
||||
color: var(--text-hint);
|
||||
}
|
||||
.field-input:hover {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
.field-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-hint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.field-with-btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.field-with-icon {
|
||||
position: relative;
|
||||
}
|
||||
.field-with-icon .field-input {
|
||||
padding-right: 34px;
|
||||
}
|
||||
.field-icon-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---------- segmented control ---------- */
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-content);
|
||||
}
|
||||
.segmented-item {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s var(--ease-out), color 0.1s var(--ease-out);
|
||||
}
|
||||
.segmented-item:hover:not(.active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.segmented-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* ---------- error box ---------- */
|
||||
.error-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--red-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--red) 40%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 10px;
|
||||
color: var(--red);
|
||||
font-size: 11px;
|
||||
animation: shake 0.32s var(--ease-out);
|
||||
}
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- overlay / dialog ---------- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--bg-base) 62%, transparent);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
animation: overlay-in 0.16s var(--ease-out) forwards;
|
||||
}
|
||||
.overlay.closing {
|
||||
animation: overlay-out 0.12s var(--ease-out) forwards;
|
||||
}
|
||||
@keyframes overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
padding: 22px;
|
||||
width: 340px;
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
animation: dialog-in 0.18s var(--ease-out);
|
||||
}
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dialog-title {
|
||||
font-size: 15.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.dialog-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.dialog-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dialog-body > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.dialog-footer .btn {
|
||||
min-width: 90px;
|
||||
}
|
||||
.dialog-footer .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* small confirm-style dialog */
|
||||
.dialog.dialog-sm {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
/* Settings dialog: fixed-width nav column + scrolling content column */
|
||||
.dialog.dialog-settings {
|
||||
width: 640px;
|
||||
}
|
||||
.settings-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
height: 420px;
|
||||
}
|
||||
.settings-nav {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
padding-right: 12px;
|
||||
}
|
||||
.settings-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.1s var(--ease-out), color 0.1s var(--ease-out);
|
||||
}
|
||||
.settings-nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.settings-nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.settings-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.settings-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.settings-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-hint);
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.settings-check-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-block + .settings-block {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
appearance: auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
}
|
||||
.settings-select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.settings-select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ---------- theme picker (settings > appearance) ---------- */
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--bg-content);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s var(--ease-out),
|
||||
background-color 0.15s var(--ease-out),
|
||||
transform 0.06s var(--ease-out);
|
||||
}
|
||||
.theme-card:hover {
|
||||
border-color: var(--text-hint);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.theme-card.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.theme-card:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.theme-card-label {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.theme-card.active .theme-card-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.theme-swatch {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Swatch previews — mimic each theme's palette */
|
||||
.theme-swatch--ember {
|
||||
background: linear-gradient(135deg, #0b0d11 40%, #181c24 70%, #d9804f 100%);
|
||||
}
|
||||
.theme-swatch--frost {
|
||||
background: linear-gradient(135deg, #f4f6fa 40%, #ffffff 70%, #4a8fe0 100%);
|
||||
}
|
||||
.theme-swatch--midnight {
|
||||
background: linear-gradient(135deg, #0d0e17 40%, #161827 70%, #8b7cf7 100%);
|
||||
}
|
||||
.theme-swatch--forest {
|
||||
background: linear-gradient(135deg, #0b120e 40%, #141f17 70%, #5bba70 100%);
|
||||
}
|
||||
.theme-swatch--dawn {
|
||||
background: linear-gradient(135deg, #faf6f3 40%, #ffffff 70%, #d9735a 100%);
|
||||
}
|
||||
.theme-swatch--ash {
|
||||
background: linear-gradient(135deg, #0a0a0c 40%, #141416 70%, #c0c4cc 100%);
|
||||
}
|
||||
.settings-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--separator);
|
||||
}
|
||||
.settings-list-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.settings-list-row-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.settings-list-row-title {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-list-row-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-hint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-value-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings-value-row .k {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.settings-value-row .v {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-empty {
|
||||
color: var(--text-hint);
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.dialog-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- context menu ---------- */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
min-width: 180px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px;
|
||||
animation: menu-in 0.12s var(--ease-out);
|
||||
}
|
||||
@keyframes menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.context-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.context-menu-item.danger {
|
||||
color: var(--red);
|
||||
}
|
||||
.context-menu-sep {
|
||||
height: 1px;
|
||||
background: var(--separator);
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
/* ---------- popover (history) ---------- */
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
width: 270px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 10px;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
animation: pop-in 0.14s var(--ease-out) forwards;
|
||||
}
|
||||
@keyframes pop-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.popover-empty {
|
||||
color: var(--text-hint);
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.popover-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.popover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.popover-row:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ---------- toggle switch (settings) ---------- */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.14s var(--ease-out);
|
||||
}
|
||||
.switch::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
transition: transform 0.14s var(--ease-out);
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
.switch.on::after {
|
||||
transform: translateX(14px);
|
||||
background: var(--on-accent);
|
||||
}
|
||||
|
||||
/* ---------- progress bar ---------- */
|
||||
.progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
.progress-fill.running {
|
||||
background: var(--accent);
|
||||
}
|
||||
.progress-fill.completed {
|
||||
background: var(--green);
|
||||
}
|
||||
.progress-fill.failed {
|
||||
background: var(--red);
|
||||
}
|
||||
.progress-fill.paused {
|
||||
background: var(--text-hint);
|
||||
}
|
||||
|
||||
/* ---------- empty state ---------- */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-hint);
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
896
frontend/css/layout.css
Normal file
896
frontend/css/layout.css
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
/* Screen-level layout: welcome screen and the main browsing shell. */
|
||||
|
||||
#screen-root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
animation: screen-in 0.2s var(--ease-out) forwards;
|
||||
}
|
||||
@keyframes screen-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= Welcome screen ================= */
|
||||
.welcome {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 11vh;
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
/* soft ember glow behind the hero so the screen isn't a flat void */
|
||||
background:
|
||||
radial-gradient(560px 300px at 50% -60px, var(--accent-soft), transparent 70%),
|
||||
var(--bg-base);
|
||||
}
|
||||
.welcome-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.welcome-logo {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(145deg, var(--accent-hover), var(--accent-dim));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--on-accent);
|
||||
box-shadow:
|
||||
0 12px 36px color-mix(in srgb, var(--accent) 28%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.welcome-title {
|
||||
margin-top: 16px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.welcome-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-hint);
|
||||
}
|
||||
.welcome-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.welcome-cta {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.welcome-actions .kbd {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.welcome-status {
|
||||
margin-top: 14px;
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.welcome-columns {
|
||||
margin-top: 24px;
|
||||
width: min(720px, 90vw);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
.welcome-card {
|
||||
flex: 1 1 300px;
|
||||
min-width: 280px;
|
||||
max-width: 352px;
|
||||
background: color-mix(in srgb, var(--bg-panel) 72%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 12px 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.welcome-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-hint);
|
||||
}
|
||||
.recent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s var(--ease-out);
|
||||
}
|
||||
.recent-row:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
/* Shared by both the welcome screen's recent-connections list (.recent-row)
|
||||
and the toolbar's history popover (.popover-row) -- unscoped so it applies
|
||||
in either parent. */
|
||||
.recent-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-time {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-hint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ================= Main shell ================= */
|
||||
.app-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Toolbar — now floats above the body with uniform rounding */
|
||||
.toolbar {
|
||||
height: var(--toolbar-h);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin: 8px 14px 0;
|
||||
background: var(--bg-toolbar);
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Body row: sidebar + main content, floating in a padded, gapless frame so
|
||||
the two panels read as one rounded pill (sidebar rounds its left corners,
|
||||
the content card rounds its right corners — see mockup "new shell
|
||||
concept"). */
|
||||
.app-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding: 0 14px 14px;
|
||||
gap: 0;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-width: 140px;
|
||||
max-width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-right: none;
|
||||
border-radius: 0 0 0 var(--radius-card);
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
margin-top: -8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-logo-badge {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(145deg, var(--accent), var(--accent-dim));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--on-accent);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sidebar-logo-text {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sidebar-section-title {
|
||||
padding: 0 16px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-hint);
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--separator);
|
||||
margin: 10px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar-footer {
|
||||
padding: 0 16px;
|
||||
font-size: 10px;
|
||||
color: var(--text-hint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-content);
|
||||
border-radius: 0 0 var(--radius-card) 0;
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Tab bar (Local + one per connection) */
|
||||
.tabbar {
|
||||
height: var(--tabbar-h);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-tab-idle);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.1s var(--ease-out), color 0.1s var(--ease-out);
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--bg-tab-active);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-hint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* localPane.js/remotePane.js render straight into this element (there's no
|
||||
separate .pane wrapper -- renderActivePane() swaps the one visible pane's
|
||||
content in place rather than stacking multiple absolutely-positioned
|
||||
panes), so it has to be the flex column container itself. */
|
||||
.tab-panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Path bar (top of each pane) */
|
||||
.path-bar {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.path-bar-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.breadcrumb-seg {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.breadcrumb-seg:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.breadcrumb-seg.current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
.breadcrumb-seg.current:hover {
|
||||
background: none;
|
||||
}
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-hint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.path-bar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-hint);
|
||||
font-size: 10.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* File table */
|
||||
.file-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.file-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 60px 80px;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sort-header {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
height: 20px;
|
||||
justify-self: start;
|
||||
}
|
||||
.file-table-header .sort-header:nth-child(2),
|
||||
.file-table-header .sort-header:nth-child(3),
|
||||
.file-table-header .sort-header:nth-child(4) {
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
.sort-header:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sort-header.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.file-table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 60px 80px;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
padding: 0 16px;
|
||||
margin: 0 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
transition: background-color 0.08s var(--ease-out);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.file-row:hover {
|
||||
background: var(--bg-row-hover);
|
||||
}
|
||||
.file-row.selected {
|
||||
background: var(--bg-row-sel);
|
||||
}
|
||||
.file-row.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
/* A dragged file/folder hovering a folder row (or "..") -- it'll spring
|
||||
open into that folder after HOVER_OPEN_DELAY, see makeDropTarget in
|
||||
dnd.js. */
|
||||
.file-row.drag-hover {
|
||||
background: var(--accent-soft);
|
||||
outline: 1px dashed var(--accent-ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.file-row-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-row-name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.file-row-modified {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-hint);
|
||||
text-align: right;
|
||||
padding-right: 12px;
|
||||
}
|
||||
.file-row-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-row-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Footer (below table) */
|
||||
.pane-footer {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px 0 16px;
|
||||
background: var(--bg-tab-idle);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.pane-footer-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
/* Queue panel */
|
||||
.queue-panel {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-queue);
|
||||
border-top: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
height: var(--queue-collapsed-h);
|
||||
transition: height 0.18s var(--ease-out);
|
||||
}
|
||||
.queue-panel.expanded {
|
||||
height: var(--queue-expanded-h);
|
||||
}
|
||||
.queue-header {
|
||||
height: var(--queue-collapsed-h);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.queue-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.queue-header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.queue-list {
|
||||
overflow-y: auto;
|
||||
height: calc(var(--queue-expanded-h) - var(--queue-collapsed-h));
|
||||
padding: 2px 0;
|
||||
}
|
||||
.queue-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.queue-task-name {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.queue-task-pct {
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.queue-task-state {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10.5px;
|
||||
min-width: 90px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.queue-task-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
height: var(--status-h);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-base);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.status-bar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ================= Per-platform adjustments ================= */
|
||||
/* initPlatform() stamps data-platform (mac/windows/linux), data-desktop
|
||||
(kde/gnome/hyprland/…) and data-tiling on <html> — see js/platform.js. */
|
||||
|
||||
/* Tiling compositors (Hyprland, sway, i3, …): the window fills its slot
|
||||
edge-to-edge, so the decorative floating-card gutter reads as wasted
|
||||
space — tighten it and square off the shell slightly. */
|
||||
html[data-tiling="true"] .app-body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Windows: Segoe UI sits a touch lower on the line box than SF/Noto;
|
||||
antialiased smoothing also renders it too thin on ClearType displays. */
|
||||
html[data-platform="windows"] body {
|
||||
-webkit-font-smoothing: auto;
|
||||
}
|
||||
|
||||
/* GNOME/KDE ship overlay scrollbars — a permanently-reserved 9px gutter
|
||||
looks foreign there; slim it down. */
|
||||
html[data-platform="linux"] ::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
/* ── Split-pane grid ─────────────────────────────────────────────────── */
|
||||
.pane-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-content);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pane-cell + .pane-cell {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.pane-cell.drop-zone {
|
||||
background: var(--bg-panel);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
z-index: 2;
|
||||
}
|
||||
.pane-cell.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Pane header */
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px 0 12px;
|
||||
background: var(--bg-tab-idle);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
.pane-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pane-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pane-split-btn,
|
||||
.pane-close-btn {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-hint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.pane-split-btn:hover,
|
||||
.pane-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Pane count badge in tab bar */
|
||||
.tab-pane-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin: 0 4px 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Pane edge drop zones ────────────────────────────────────────────── */
|
||||
.pane-zone {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease, background 0.1s ease;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
.pane-zone--left { left: 0; top: 0; bottom: 0; width: 22%; }
|
||||
.pane-zone--right { right: 0; top: 0; bottom: 0; width: 22%; }
|
||||
.pane-zone--top { left: 0; right: 0; top: 0; height: 22%; }
|
||||
.pane-zone--bottom { left: 0; right: 0; bottom: 0; height: 22%; }
|
||||
.pane-zone--center { left: 22%; right: 22%; top: 22%; bottom: 22%; }
|
||||
.pane-zone--swap { left: 0; right: 0; top: 28px; bottom: 0; } /* below header */
|
||||
|
||||
.pane-cell.drop-active[data-drop-zone="left"] .pane-zone--left,
|
||||
.pane-cell.drop-active[data-drop-zone="right"] .pane-zone--right,
|
||||
.pane-cell.drop-active[data-drop-zone="top"] .pane-zone--top,
|
||||
.pane-cell.drop-active[data-drop-zone="bottom"] .pane-zone--bottom,
|
||||
.pane-cell.drop-active[data-drop-zone="center"] .pane-zone--center {
|
||||
opacity: 1;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.pane-cell.drop-active[data-drop-zone="center"] .pane-zone--center {
|
||||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Pane "spring-loading": shown while a dragged tab hovers a pane that
|
||||
hasn't opened as a drop target yet (see HOVER_OPEN_DELAY in dnd.js) --
|
||||
distinct from .drop-active so the pause before zones appear still reads
|
||||
as a response to the hover, not a stall. */
|
||||
.pane-cell.drop-pending {
|
||||
outline: 2px dashed var(--accent-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Tab dragging visual */
|
||||
.tab-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.pane-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ── Panel show/hide animations ──────────────────────────────────────── */
|
||||
|
||||
/* Sidebar: collapse width + fade */
|
||||
.sidebar {
|
||||
transition: width 0.25s var(--ease-out),
|
||||
min-width 0.25s var(--ease-out),
|
||||
padding-left 0.25s var(--ease-out),
|
||||
padding-right 0.25s var(--ease-out),
|
||||
border-width 0.25s var(--ease-out),
|
||||
opacity 0.2s var(--ease-out);
|
||||
}
|
||||
.sidebar.hidden {
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
border: none !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Toolbar: collapse height + fade */
|
||||
.toolbar {
|
||||
transition: height 0.25s var(--ease-out),
|
||||
margin 0.25s var(--ease-out),
|
||||
padding 0.25s var(--ease-out),
|
||||
opacity 0.2s var(--ease-out);
|
||||
}
|
||||
.toolbar.hidden {
|
||||
height: 0 !important;
|
||||
margin: 0 14px !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
border: none !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Status bar: collapse height + fade */
|
||||
.status-bar {
|
||||
transition: height 0.25s var(--ease-out),
|
||||
opacity 0.2s var(--ease-out);
|
||||
}
|
||||
.status-bar.hidden {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Queue panel: collapse height + fade */
|
||||
.queue-panel {
|
||||
transition: height 0.22s var(--ease-out),
|
||||
opacity 0.18s var(--ease-out);
|
||||
}
|
||||
.queue-panel.hidden {
|
||||
height: 0 !important;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Main-content corner rounding ────────────────────────────────────── */
|
||||
.main-content {
|
||||
transition: border-radius 0.25s var(--ease-out);
|
||||
}
|
||||
/* Default: bottom-right only (sidebar left + toolbar top are flat) */
|
||||
.main-content:not(.no-sidebar):not(.no-toolbar):not(.no-status) {
|
||||
border-radius: 0 0 var(--radius-card) 0;
|
||||
}
|
||||
/* Sidebar hidden -- round left side */
|
||||
.main-content.no-sidebar:not(.no-toolbar):not(.no-status) {
|
||||
border-radius: 0 var(--radius-card) var(--radius-card) 0;
|
||||
}
|
||||
/* Toolbar hidden -- round top */
|
||||
.main-content.no-toolbar:not(.no-sidebar):not(.no-status) {
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
}
|
||||
/* Bottom hidden -- round bottom */
|
||||
.main-content.no-status:not(.no-sidebar):not(.no-toolbar) {
|
||||
border-radius: 0 0 var(--radius-card) var(--radius-card);
|
||||
}
|
||||
/* Sidebar + Toolbar hidden */
|
||||
.main-content.no-sidebar.no-toolbar:not(.no-status) {
|
||||
border-radius: var(--radius-card) var(--radius-card) var(--radius-card) 0;
|
||||
}
|
||||
/* Sidebar + Bottom hidden */
|
||||
.main-content.no-sidebar.no-status:not(.no-toolbar) {
|
||||
border-radius: 0 var(--radius-card) var(--radius-card) var(--radius-card);
|
||||
}
|
||||
/* Toolbar + Bottom hidden */
|
||||
.main-content.no-toolbar.no-status:not(.no-sidebar) {
|
||||
border-radius: var(--radius-card) var(--radius-card) var(--radius-card) var(--radius-card);
|
||||
}
|
||||
/* All three hidden -- fully rounded */
|
||||
.main-content.no-sidebar.no-toolbar.no-status {
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
371
frontend/css/theme.css
Normal file
371
frontend/css/theme.css
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/* ── Wherry Themes ─────────────────────────────────────────────────────────
|
||||
Six themes: Ember (dark · copper), Frost (light · blue), Midnight
|
||||
(dark · indigo), Forest (dark · green), Dawn (light · peach), Ash
|
||||
(high-contrast dark · neutral).
|
||||
Every colour the UI uses is a token; component styles never hardcode a
|
||||
hex. Soft/hover variants are derived with color-mix() so the whole
|
||||
palette can be retuned by touching only the base tokens.
|
||||
|
||||
The active theme is selected via `data-theme="…"` on the <html> element.
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ══════════════ 1. Ember (default dark · copper accent) ═══════════════ */
|
||||
:root,
|
||||
[data-theme="ember"] {
|
||||
--bg-base: #0b0d11;
|
||||
--bg-panel: #181c24;
|
||||
--bg-content: #12151b;
|
||||
--bg-toolbar: #12151b;
|
||||
--bg-tab-active: #12151b;
|
||||
--bg-tab-idle: #14171e;
|
||||
--bg-queue: #0f1116;
|
||||
--bg-header: #10131a;
|
||||
--bg-row-hover: rgba(255, 255, 255, 0.055);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
|
||||
--accent: #d9804f;
|
||||
--accent-hover: #e28e5d;
|
||||
--accent-dim: #9c5433;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
--on-accent: #221208;
|
||||
|
||||
--green: #7dc98f;
|
||||
--green-soft: color-mix(in srgb, var(--green) 14%, transparent);
|
||||
--red: #e2604c;
|
||||
--red-hover: #e97561;
|
||||
--red-soft: color-mix(in srgb, var(--red) 14%, transparent);
|
||||
--yellow: #e3b341;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 14%, transparent);
|
||||
--blue: #6aa9e9;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 14%, transparent);
|
||||
|
||||
--text-primary: #eceef2;
|
||||
--text-dim: #99a0ac;
|
||||
--text-hint: #656c79;
|
||||
|
||||
--border: #232833;
|
||||
--separator: #262b35;
|
||||
}
|
||||
|
||||
/* ══════════════ 2. Frost (light · cool blue accent) ════════════════════ */
|
||||
[data-theme="frost"] {
|
||||
--bg-base: #f4f6fa;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-content: #fafbfd;
|
||||
--bg-toolbar: #ffffff;
|
||||
--bg-tab-active: #ffffff;
|
||||
--bg-tab-idle: #f0f2f6;
|
||||
--bg-queue: #f8f9fc;
|
||||
--bg-header: #edf0f5;
|
||||
--bg-row-hover: rgba(0, 0, 0, 0.04);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
|
||||
--accent: #4a8fe0;
|
||||
--accent-hover: #5d9de8;
|
||||
--accent-dim: #3570b0;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--green: #38a169;
|
||||
--green-soft: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
--red: #d95c4a;
|
||||
--red-hover: #e06d5c;
|
||||
--red-soft: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
--yellow: #c9952e;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||
--blue: #4a8fe0;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 12%, transparent);
|
||||
|
||||
--text-primary: #1a1d23;
|
||||
--text-dim: #5f6672;
|
||||
--text-hint: #9098a5;
|
||||
|
||||
--border: #dce0e8;
|
||||
--separator: #e2e6ee;
|
||||
}
|
||||
|
||||
/* ══════════════ 3. Midnight (dark · indigo accent) ══════════════════════ */
|
||||
[data-theme="midnight"] {
|
||||
--bg-base: #0d0e17;
|
||||
--bg-panel: #161827;
|
||||
--bg-content: #10121e;
|
||||
--bg-toolbar: #10121e;
|
||||
--bg-tab-active: #10121e;
|
||||
--bg-tab-idle: #131523;
|
||||
--bg-queue: #0e0f1a;
|
||||
--bg-header: #0f111d;
|
||||
--bg-row-hover: rgba(255, 255, 255, 0.055);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
|
||||
--accent: #8b7cf7;
|
||||
--accent-hover: #9d90f9;
|
||||
--accent-dim: #6355c9;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
--on-accent: #110e24;
|
||||
|
||||
--green: #6fcb8a;
|
||||
--green-soft: color-mix(in srgb, var(--green) 14%, transparent);
|
||||
--red: #e06070;
|
||||
--red-hover: #e87481;
|
||||
--red-soft: color-mix(in srgb, var(--red) 14%, transparent);
|
||||
--yellow: #dbb84a;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 14%, transparent);
|
||||
--blue: #6a8ce8;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 14%, transparent);
|
||||
|
||||
--text-primary: #e8eaf2;
|
||||
--text-dim: #949ab0;
|
||||
--text-hint: #5e6480;
|
||||
|
||||
--border: #222540;
|
||||
--separator: #262948;
|
||||
}
|
||||
|
||||
/* ══════════════ 4. Forest (dark · green accent) ═════════════════════════ */
|
||||
[data-theme="forest"] {
|
||||
--bg-base: #0b120e;
|
||||
--bg-panel: #141f17;
|
||||
--bg-content: #0e1611;
|
||||
--bg-toolbar: #0e1611;
|
||||
--bg-tab-active: #0e1611;
|
||||
--bg-tab-idle: #111a13;
|
||||
--bg-queue: #0c130f;
|
||||
--bg-header: #0d1510;
|
||||
--bg-row-hover: rgba(255, 255, 255, 0.055);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 17%, transparent);
|
||||
|
||||
--accent: #5bba70;
|
||||
--accent-hover: #6ec982;
|
||||
--accent-dim: #3d8f4f;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
--on-accent: #0c1e10;
|
||||
|
||||
--green: #5bba70;
|
||||
--green-soft: color-mix(in srgb, var(--green) 14%, transparent);
|
||||
--red: #d96050;
|
||||
--red-hover: #e07262;
|
||||
--red-soft: color-mix(in srgb, var(--red) 14%, transparent);
|
||||
--yellow: #d4ad40;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 14%, transparent);
|
||||
--blue: #5b94c9;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 14%, transparent);
|
||||
|
||||
--text-primary: #e2efe5;
|
||||
--text-dim: #91a899;
|
||||
--text-hint: #5c7565;
|
||||
|
||||
--border: #1e3325;
|
||||
--separator: #223b29;
|
||||
}
|
||||
|
||||
/* ══════════════ 5. Dawn (light · warm peach accent) ═════════════════════ */
|
||||
[data-theme="dawn"] {
|
||||
--bg-base: #faf6f3;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-content: #fdfaf8;
|
||||
--bg-toolbar: #ffffff;
|
||||
--bg-tab-active: #ffffff;
|
||||
--bg-tab-idle: #f6f0ea;
|
||||
--bg-queue: #fbf7f4;
|
||||
--bg-header: #f2ebe5;
|
||||
--bg-row-hover: rgba(0, 0, 0, 0.04);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
|
||||
--accent: #d9735a;
|
||||
--accent-hover: #e0846c;
|
||||
--accent-dim: #b05a44;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--green: #4b8b5e;
|
||||
--green-soft: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
--red: #c95a4a;
|
||||
--red-hover: #d86c5c;
|
||||
--red-soft: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
--yellow: #b8993a;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 12%, transparent);
|
||||
--blue: #5a80b8;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 12%, transparent);
|
||||
|
||||
--text-primary: #2c2420;
|
||||
--text-dim: #736760;
|
||||
--text-hint: #9f928a;
|
||||
|
||||
--border: #e6dbd3;
|
||||
--separator: #ede2da;
|
||||
}
|
||||
|
||||
/* ══════════════ 6. Ash (high-contrast dark · neutral gray) ══════════════ */
|
||||
[data-theme="ash"] {
|
||||
--bg-base: #0a0a0c;
|
||||
--bg-panel: #141416;
|
||||
--bg-content: #0e0e10;
|
||||
--bg-toolbar: #0e0e10;
|
||||
--bg-tab-active: #0e0e10;
|
||||
--bg-tab-idle: #111113;
|
||||
--bg-queue: #0c0c0e;
|
||||
--bg-header: #0d0d0f;
|
||||
--bg-row-hover: rgba(255, 255, 255, 0.07);
|
||||
--bg-row-sel: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
|
||||
--accent: #c0c4cc;
|
||||
--accent-hover: #d4d8e0;
|
||||
--accent-dim: #8a8e98;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
--accent-ring: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
--on-accent: #0a0a0c;
|
||||
|
||||
--green: #8fd4a0;
|
||||
--green-soft: color-mix(in srgb, var(--green) 16%, transparent);
|
||||
--red: #f07060;
|
||||
--red-hover: #f58476;
|
||||
--red-soft: color-mix(in srgb, var(--red) 16%, transparent);
|
||||
--yellow: #e8c860;
|
||||
--yellow-soft: color-mix(in srgb, var(--yellow) 16%, transparent);
|
||||
--blue: #80b4e8;
|
||||
--blue-soft: color-mix(in srgb, var(--blue) 16%, transparent);
|
||||
|
||||
--text-primary: #f0f2f5;
|
||||
--text-dim: #b0b4be;
|
||||
--text-hint: #787c88;
|
||||
|
||||
--border: #2c2e36;
|
||||
--separator: #32343c;
|
||||
}
|
||||
|
||||
/* ══════════════ Shared base styles ════════════════════════════════════════ */
|
||||
|
||||
/* Sizes — identical across all themes */
|
||||
:root {
|
||||
--sidebar-w: 216px;
|
||||
--toolbar-h: 52px;
|
||||
--tabbar-h: 36px;
|
||||
--queue-collapsed-h: 34px;
|
||||
--queue-expanded-h: 192px;
|
||||
--status-h: 28px;
|
||||
--row-h: 32px;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 10px;
|
||||
--radius-card: 14px;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text",
|
||||
"Segoe UI", "Noto Sans", "Ubuntu", "Cantarell", "DejaVu Sans", sans-serif;
|
||||
--font-mono: "SF Mono", ui-monospace, Menlo, Consolas, "Noto Sans Mono",
|
||||
"DejaVu Sans Mono", monospace;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Transition for theme switching — animates colour changes smoothly */
|
||||
--theme-transition: background-color 0.3s var(--ease-out),
|
||||
color 0.3s var(--ease-out),
|
||||
border-color 0.3s var(--ease-out),
|
||||
box-shadow 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
/* Apply theme transitions when [data-theme] changes */
|
||||
html.theme-transitioning,
|
||||
html.theme-transitioning *,
|
||||
html.theme-transitioning *::before,
|
||||
html.theme-transitioning *::after {
|
||||
transition: var(--theme-transition) !important;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-ring);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-hint);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ── Utility text-colour classes ─────────────────────────────────────────── */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-hint { color: var(--text-hint); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-green { color: var(--green); }
|
||||
.text-red { color: var(--red); }
|
||||
.text-yellow { color: var(--yellow); }
|
||||
.text-blue { color: var(--blue); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Wherry</title>
|
||||
<link rel="stylesheet" href="css/theme.css" />
|
||||
<link rel="stylesheet" href="css/components.css" />
|
||||
<link rel="stylesheet" href="css/layout.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="screen-root"></div>
|
||||
</div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
frontend/js/components/breadcrumbs.js
Normal file
33
frontend/js/components/breadcrumbs.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/** Renders a `/`-separated path as clickable breadcrumb segments into `container`. */
|
||||
export function renderBreadcrumbs(container, path, onNavigate) {
|
||||
container.innerHTML = "";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
||||
const root = document.createElement("button");
|
||||
root.type = "button";
|
||||
root.className = "breadcrumb-seg";
|
||||
root.textContent = "/";
|
||||
root.addEventListener("click", () => onNavigate("/"));
|
||||
container.appendChild(root);
|
||||
|
||||
let acc = "";
|
||||
parts.forEach((part, i) => {
|
||||
acc += `/${part}`;
|
||||
const isLast = i === parts.length - 1;
|
||||
const seg = document.createElement(isLast ? "span" : "button");
|
||||
seg.className = `breadcrumb-seg ${isLast ? "current" : ""}`;
|
||||
seg.textContent = part;
|
||||
if (!isLast) {
|
||||
const target = acc;
|
||||
seg.type = "button";
|
||||
seg.addEventListener("click", () => onNavigate(target));
|
||||
}
|
||||
container.appendChild(seg);
|
||||
if (!isLast) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "breadcrumb-sep";
|
||||
sep.textContent = "/";
|
||||
container.appendChild(sep);
|
||||
}
|
||||
});
|
||||
}
|
||||
233
frontend/js/components/connectDialog.js
Normal file
233
frontend/js/components/connectDialog.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// "New Connection" modal — src/ui/dialogs/connection/{view,form}.rs equivalent.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { buildParamsFromForm, spawnConnect, DEFAULT_PORTS } from "../connectActions.js";
|
||||
import { escapeHtml as escapeAttr } from "../dom.js";
|
||||
|
||||
const PROTOCOLS = ["sftp", "ftp", "ftps"];
|
||||
const PROTOCOL_LABELS = { sftp: "SFTP", ftp: "FTP", ftps: "FTPS" };
|
||||
|
||||
let form = emptyForm();
|
||||
let overlayEl = null;
|
||||
let lastOpenSig = null;
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
protocol: "sftp",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
keyPath: "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Called by the toolbar "New Connection" button and the welcome screen CTA. */
|
||||
export function openNew() {
|
||||
form = emptyForm();
|
||||
state.connectError = "";
|
||||
state.showConnectDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Called from history "Edit" — prefills everything. */
|
||||
export function openForEdit(entry) {
|
||||
form = {
|
||||
protocol: entry.protocol,
|
||||
host: entry.host,
|
||||
port: String(entry.port),
|
||||
username: entry.username,
|
||||
password: "",
|
||||
keyPath: entry.keyPath ?? "",
|
||||
label: "",
|
||||
};
|
||||
state.connectError = "";
|
||||
state.showConnectDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.connectLoading) return;
|
||||
state.showConnectDialog = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (state.connectLoading) return;
|
||||
const result = buildParamsFromForm(form);
|
||||
if (!result.ok) {
|
||||
state.connectError = t(result.error);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await spawnConnect(result.params);
|
||||
}
|
||||
|
||||
async function browseKeyFile() {
|
||||
try {
|
||||
const dialog = window.__TAURI__.dialog;
|
||||
const path = await dialog.open({ title: "Choose SSH key", multiple: false, directory: false });
|
||||
if (typeof path === "string") {
|
||||
form.keyPath = path;
|
||||
const input = overlayEl?.querySelector("[data-field=keyPath]");
|
||||
if (input) input.value = path;
|
||||
}
|
||||
} catch {
|
||||
/* dialog plugin unavailable / user cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
|
||||
<span class="dialog-title">${t("connect.title")}</span>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="segmented" data-role="protocol">
|
||||
${PROTOCOLS.map((p) => `<button type="button" class="segmented-item ${form.protocol === p ? "active" : ""}" data-protocol="${p}">${PROTOCOL_LABELS[p]}</button>`).join("")}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.host")}</span>
|
||||
<input class="field-input" data-field="host" type="text" value="${escapeAttr(form.host)}" placeholder="example.com" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex:1">
|
||||
<span class="field-label">${t("connect.username")}</span>
|
||||
<input class="field-input" data-field="username" type="text" value="${escapeAttr(form.username)}" />
|
||||
</div>
|
||||
<div class="field" style="width:80px">
|
||||
<span class="field-label">${t("connect.port")}</span>
|
||||
<input class="field-input" data-field="port" type="text" value="${escapeAttr(form.port)}" placeholder="${DEFAULT_PORTS[form.protocol]}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.password")}</span>
|
||||
<div class="field-with-icon">
|
||||
<input class="field-input" data-field="password" type="password" value="${escapeAttr(form.password)}" placeholder="" />
|
||||
<button type="button" class="field-icon-btn" data-action="toggle-pass" title="Show/hide">${iconMarkup("eye", 15)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-role="key-section" style="display:${form.protocol === "sftp" ? "block" : "none"}">
|
||||
<p class="field-hint">${t("connect.keyHint")}</p>
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.keyFile")}</span>
|
||||
<div class="field-with-btn">
|
||||
<input class="field-input" data-field="keyPath" type="text" value="${escapeAttr(form.keyPath)}" />
|
||||
<button type="button" class="btn btn-ghost" data-action="browse" style="flex-shrink:0">${t("connect.browse")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.label")}</span>
|
||||
<input class="field-input" data-field="label" type="text" value="${escapeAttr(form.label)}" />
|
||||
</div>
|
||||
|
||||
<div data-role="error"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-accent" data-action="submit">${t("connect.connect")}</button>
|
||||
<span data-role="spinner" style="margin-left:8px"></span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
|
||||
dialog.querySelectorAll('[data-action="close"]').forEach((el) =>
|
||||
el.addEventListener("click", close),
|
||||
);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
dialog.querySelector('[data-action="browse"]').addEventListener("click", browseKeyFile);
|
||||
|
||||
dialog.querySelectorAll("[data-field]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
form[input.dataset.field] = input.value;
|
||||
});
|
||||
});
|
||||
|
||||
const passInput = dialog.querySelector('[data-field="password"]');
|
||||
dialog.querySelector('[data-action="toggle-pass"]').addEventListener("click", (e) => {
|
||||
const showing = passInput.type === "text";
|
||||
passInput.type = showing ? "password" : "text";
|
||||
e.currentTarget.innerHTML = iconMarkup(showing ? "eye" : "eyeClosed", 15);
|
||||
});
|
||||
|
||||
dialog.querySelectorAll("[data-protocol]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const proto = btn.dataset.protocol;
|
||||
form.protocol = proto;
|
||||
if (!form.port) {
|
||||
dialog.querySelector('[data-field="port"]').placeholder = String(DEFAULT_PORTS[proto]);
|
||||
}
|
||||
dialog.querySelectorAll("[data-protocol]").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
dialog.querySelector('[data-role="key-section"]').style.display = proto === "sftp" ? "block" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
dialog.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
|
||||
return { overlay, dialog };
|
||||
}
|
||||
|
||||
function updateDynamic(dialog) {
|
||||
const errRole = dialog.querySelector('[data-role="error"]');
|
||||
errRole.innerHTML = state.connectError
|
||||
? `<div class="error-box">${iconMarkup("dangerTriangle", 14)}<span>${escapeAttr(state.connectError)}</span></div>`
|
||||
: "";
|
||||
|
||||
const submitBtn = dialog.querySelector('[data-action="submit"]');
|
||||
submitBtn.disabled = state.connectLoading;
|
||||
submitBtn.textContent = state.connectLoading ? t("connect.connecting") : t("connect.connect");
|
||||
const cancelBtn = dialog.querySelector('[data-action="close"]');
|
||||
cancelBtn.disabled = state.connectLoading;
|
||||
|
||||
dialog.querySelector('[data-role="spinner"]').innerHTML = state.connectLoading
|
||||
? '<span class="spinner"></span>'
|
||||
: "";
|
||||
}
|
||||
|
||||
export function renderConnectDialog() {
|
||||
const sig = JSON.stringify([state.showConnectDialog]);
|
||||
if (!state.showConnectDialog) {
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
lastOpenSig = sig;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sig !== lastOpenSig || !overlayEl) {
|
||||
if (overlayEl) overlayEl.remove();
|
||||
const { overlay, dialog } = build();
|
||||
document.body.appendChild(overlay);
|
||||
overlayEl = overlay;
|
||||
overlayEl._dialog = dialog;
|
||||
setTimeout(() => dialog.querySelector('[data-field="host"]').focus(), 0);
|
||||
lastOpenSig = sig;
|
||||
}
|
||||
|
||||
updateDynamic(overlayEl._dialog);
|
||||
}
|
||||
58
frontend/js/components/contextMenu.js
Normal file
58
frontend/js/components/contextMenu.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { iconMarkup } from "../icons.js";
|
||||
|
||||
let openMenuEl = null;
|
||||
|
||||
function closeOpenMenu() {
|
||||
if (openMenuEl) {
|
||||
openMenuEl.remove();
|
||||
openMenuEl = null;
|
||||
document.removeEventListener("mousedown", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (openMenuEl && !openMenuEl.contains(e.target)) closeOpenMenu();
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") closeOpenMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* items: Array<{ label, icon?, danger?, onClick } | 'sep'>
|
||||
*/
|
||||
export function showContextMenu(x, y, items) {
|
||||
closeOpenMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
for (const item of items) {
|
||||
if (item === "sep") {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "context-menu-sep";
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
btn.innerHTML = `${item.icon ? iconMarkup(item.icon, 14) : ""}<span>${item.label}</span>`;
|
||||
btn.addEventListener("click", () => {
|
||||
closeOpenMenu();
|
||||
item.onClick();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;
|
||||
menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;
|
||||
|
||||
openMenuEl = menu;
|
||||
setTimeout(() => {
|
||||
document.addEventListener("mousedown", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
}, 0);
|
||||
}
|
||||
176
frontend/js/components/fileTable.js
Normal file
176
frontend/js/components/fileTable.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// Reusable file listing table shared by the Local and Remote panes — mirrors
|
||||
// the old src/ui/panels/file_pane module (table.rs/row.rs/sort.rs collapsed
|
||||
// into one file since there's no split-module pressure in JS).
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup, fileIconFor } from "../icons.js";
|
||||
import { formatSize, formatTime } from "../format.js";
|
||||
import { makeDraggable, makeDropTarget } from "../dnd.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
function sortEntries(entries, sort) {
|
||||
const dotdotIdx = entries.findIndex((e) => e.name === "..");
|
||||
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
|
||||
const rest = entries.filter((e) => e.name !== "..");
|
||||
|
||||
rest.sort((a, b) => {
|
||||
const aDir = a.kind === "dir";
|
||||
const bDir = b.kind === "dir";
|
||||
if (aDir !== bDir) return aDir ? -1 : 1;
|
||||
|
||||
let cmp = 0;
|
||||
switch (sort.col) {
|
||||
case "size":
|
||||
cmp = (a.size ?? 0) - (b.size ?? 0);
|
||||
break;
|
||||
case "kind":
|
||||
cmp = a.kind.localeCompare(b.kind);
|
||||
break;
|
||||
case "modified":
|
||||
cmp = (a.modified ?? 0) - (b.modified ?? 0);
|
||||
break;
|
||||
default:
|
||||
cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
}
|
||||
return sort.dir === "desc" ? -cmp : cmp;
|
||||
});
|
||||
|
||||
return dotdot ? [dotdot, ...rest] : rest;
|
||||
}
|
||||
|
||||
function rowIcon(entry) {
|
||||
if (entry.name === "..") return ["arrowUp", "icon--inactive"];
|
||||
if (entry.kind === "dir") return ["folder", "icon--important"];
|
||||
if (entry.kind === "symlink") return ["link", "icon--important"];
|
||||
return fileIconFor(entry.name);
|
||||
}
|
||||
|
||||
function typeLabel(entry) {
|
||||
if (entry.kind === "dir") return t("panels.typeDir");
|
||||
if (entry.kind === "symlink") return t("panels.typeLink");
|
||||
return t("panels.typeFile");
|
||||
}
|
||||
|
||||
function sortHeaderHtml(label, col, sort) {
|
||||
const active = sort.col === col;
|
||||
const arrow = active ? (sort.dir === "asc" ? " ↑" : " ↓") : "";
|
||||
return `<button type="button" class="sort-header ${active ? "active" : ""}" data-col="${col}">${label}${arrow}</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* opts:
|
||||
* - entries: FileEntry[]
|
||||
* - selected: string[] (names of selected entries)
|
||||
* - anchor: string|null (name shift-click range selection extends from)
|
||||
* - sort: { col, dir }
|
||||
* - onSort(nextSort)
|
||||
* - onSelect(names, anchor)
|
||||
* - onOpen(name)
|
||||
* - dragPayload(entry) => payload|null
|
||||
* - onDropOnEntry(entry, payload) only called for dir/".." rows
|
||||
* - onHoverOpen(entry) spring-loaded open: fires after a drag lingers on
|
||||
* a dir/".." row for HOVER_OPEN_DELAY, without
|
||||
* ending the drag -- only called for dir/".." rows
|
||||
* - contextMenuItems(entry, selectedEntries) => menu items for showContextMenu, or null to skip
|
||||
*/
|
||||
export function renderFileTable(container, opts) {
|
||||
const scrollPos = container.querySelector(".file-table-body")?.scrollTop ?? 0;
|
||||
container.innerHTML = "";
|
||||
container.classList.add("file-table-wrap");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-table-header";
|
||||
header.innerHTML = `
|
||||
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colType"), "kind", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colSize"), "size", opts.sort)}
|
||||
`;
|
||||
header.querySelectorAll(".sort-header").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const next =
|
||||
opts.sort.col === col
|
||||
? { col, dir: opts.sort.dir === "asc" ? "desc" : "asc" }
|
||||
: { col, dir: "asc" };
|
||||
opts.onSort(next);
|
||||
});
|
||||
});
|
||||
container.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-table-body";
|
||||
container.appendChild(body);
|
||||
|
||||
const sorted = sortEntries(opts.entries, opts.sort);
|
||||
const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
|
||||
|
||||
for (const entry of sorted) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "file-row";
|
||||
if (opts.selected.includes(entry.name)) row.classList.add("selected");
|
||||
|
||||
const [iconName, iconColor] = rowIcon(entry);
|
||||
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="file-row-name">
|
||||
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
|
||||
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
|
||||
<div class="file-row-type">${typeLabel(entry)}</div>
|
||||
<div class="file-row-size mono">${formatSize(entry.size)}</div>
|
||||
`;
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
if (entry.name === "..") {
|
||||
opts.onSelect([], null);
|
||||
return;
|
||||
}
|
||||
const idx = selectableNames.indexOf(entry.name);
|
||||
if (e.shiftKey && opts.anchor && selectableNames.includes(opts.anchor)) {
|
||||
const anchorIdx = selectableNames.indexOf(opts.anchor);
|
||||
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
|
||||
opts.onSelect(selectableNames.slice(lo, hi + 1), opts.anchor);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
const next = new Set(opts.selected);
|
||||
if (next.has(entry.name)) next.delete(entry.name);
|
||||
else next.add(entry.name);
|
||||
opts.onSelect(selectableNames.filter((n) => next.has(n)), entry.name);
|
||||
} else {
|
||||
opts.onSelect([entry.name], entry.name);
|
||||
}
|
||||
});
|
||||
row.addEventListener("dblclick", () => opts.onOpen(entry.name));
|
||||
|
||||
if (entry.name !== "..") {
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
let selection = opts.selected;
|
||||
if (!selection.includes(entry.name)) {
|
||||
selection = [entry.name];
|
||||
opts.onSelect(selection, entry.name);
|
||||
}
|
||||
const selectedEntries = sorted.filter((en) => selection.includes(en.name));
|
||||
const items = opts.contextMenuItems?.(entry, selectedEntries);
|
||||
if (items) showContextMenu(e.clientX, e.clientY, items);
|
||||
});
|
||||
|
||||
const payload = opts.dragPayload?.(entry);
|
||||
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
|
||||
}
|
||||
|
||||
if (entry.kind === "dir" || entry.name === "..") {
|
||||
makeDropTarget(row, {
|
||||
onDrop: (payload) => opts.onDropOnEntry?.(entry, payload),
|
||||
onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
|
||||
onHoverHold: () => opts.onHoverOpen?.(entry),
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
body.scrollTop = scrollPos;
|
||||
}
|
||||
47
frontend/js/components/historyList.js
Normal file
47
frontend/js/components/historyList.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Shared "recent connections" row renderer used by both the welcome screen
|
||||
// and the toolbar's History popover.
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { reconnectFromHistory, saveHistoryAsSite } from "../connectActions.js";
|
||||
import { openForEdit } from "./connectDialog.js";
|
||||
|
||||
/** entries: HistoryEntry[]; rowClass defaults to "recent-row". */
|
||||
export function renderHistoryRows(container, entries, { rowClass = "recent-row", onAfterClick } = {}) {
|
||||
container.innerHTML = "";
|
||||
for (const entry of entries) {
|
||||
const row = document.createElement("div");
|
||||
row.className = rowClass;
|
||||
row.innerHTML = `
|
||||
<span class="icon">${iconMarkup("serverSquare", 13)}</span>
|
||||
<span class="recent-label">${entry.username}@${entry.host}:${entry.port}</span>
|
||||
<span class="recent-time">${entry.connectedAt}</span>
|
||||
`;
|
||||
row.addEventListener("click", () => {
|
||||
reconnectFromHistory(entry);
|
||||
onAfterClick?.();
|
||||
});
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, [
|
||||
{
|
||||
label: t("common.edit"),
|
||||
icon: "pen",
|
||||
onClick: () => {
|
||||
openForEdit(entry);
|
||||
onAfterClick?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.save"),
|
||||
icon: "star",
|
||||
onClick: () => {
|
||||
saveHistoryAsSite(entry);
|
||||
onAfterClick?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
68
frontend/js/components/historyPopup.js
Normal file
68
frontend/js/components/historyPopup.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { renderHistoryRows } from "./historyList.js";
|
||||
|
||||
let popoverEl = null;
|
||||
let pendingAnchorRect = null;
|
||||
|
||||
function close() {
|
||||
state.showHistoryPopup = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function toggle(anchorEl) {
|
||||
// Measure the anchor before notify() -- the toolbar re-renders itself
|
||||
// synchronously inside notify() (its computeSig() includes
|
||||
// showHistoryPopup), which discards and recreates this exact button. A
|
||||
// rect read after that point, or against a stale element reference, would
|
||||
// measure a detached node and yield an all-zero rect.
|
||||
pendingAnchorRect = anchorEl.getBoundingClientRect();
|
||||
state.showHistoryPopup = !state.showHistoryPopup;
|
||||
notify();
|
||||
if (state.showHistoryPopup) positionNear(pendingAnchorRect);
|
||||
}
|
||||
|
||||
function positionNear(rect) {
|
||||
if (!popoverEl || !rect) return;
|
||||
popoverEl.style.top = `${rect.bottom + 6}px`;
|
||||
popoverEl.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (popoverEl && !popoverEl.contains(e.target) && !e.target.closest("[data-history-toggle]")) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHistoryPopup() {
|
||||
if (!state.showHistoryPopup) {
|
||||
if (popoverEl) {
|
||||
document.removeEventListener("mousedown", onOutsideClick, true);
|
||||
popoverEl.remove();
|
||||
popoverEl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!popoverEl) {
|
||||
popoverEl = document.createElement("div");
|
||||
popoverEl.className = "popover";
|
||||
document.body.appendChild(popoverEl);
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutsideClick, true), 0);
|
||||
}
|
||||
|
||||
popoverEl.innerHTML = `
|
||||
<div class="popover-header">
|
||||
<span class="icon">${iconMarkup("serverSquare", 12)}</span>
|
||||
<span>${t("history.recentConnections")}</span>
|
||||
</div>
|
||||
${state.history.length === 0 ? `<div class="popover-empty">${t("history.noHistory")}</div>` : '<div class="popover-list" data-role="list"></div>'}
|
||||
`;
|
||||
|
||||
if (state.history.length > 0) {
|
||||
renderHistoryRows(popoverEl.querySelector('[data-role="list"]'), state.history, {
|
||||
rowClass: "popover-row",
|
||||
onAfterClick: close,
|
||||
});
|
||||
}
|
||||
}
|
||||
140
frontend/js/components/localPane.js
Normal file
140
frontend/js/components/localPane.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { state, notify, activeTab } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize } from "../format.js";
|
||||
import { renderFileTable } from "./fileTable.js";
|
||||
import { renderBreadcrumbs } from "./breadcrumbs.js";
|
||||
import { makeDropTarget } from "../dnd.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { openRename, openDelete } from "./opDialogs.js";
|
||||
import {
|
||||
navigateLocal,
|
||||
openLocalEntry,
|
||||
addBookmark,
|
||||
handleDropOnLocal,
|
||||
queueUpload,
|
||||
queueUploadMany,
|
||||
} from "../fsActions.js";
|
||||
|
||||
export function renderLocalPane(container) {
|
||||
container.innerHTML = `
|
||||
<div class="path-bar">
|
||||
<span class="path-bar-label">${t("panels.localLabel")}</span>
|
||||
<div class="breadcrumbs" data-role="breadcrumbs"></div>
|
||||
</div>
|
||||
<div data-role="table" class="file-table-wrap"></div>
|
||||
<div class="pane-footer">
|
||||
<span data-role="count" class="text-dim" style="font-size:11px"></span>
|
||||
<span data-role="size" class="text-hint" style="font-size:11px"></span>
|
||||
<div class="pane-footer-right">
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="bookmark" title="${t("panels.bookmarkThisFolder")}">${iconMarkup("star", 13)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="bookmark"]').addEventListener("click", () => {
|
||||
const path = state.local.path;
|
||||
const name = path.split("/").filter(Boolean).pop() ?? "Bookmark";
|
||||
addBookmark(name, path);
|
||||
});
|
||||
|
||||
makeDropTarget(container, {
|
||||
onDrop: (payload) => handleDropOnLocal(payload, state.local.path),
|
||||
onHoverChange: (hover) => container.classList.toggle("drag-over", hover),
|
||||
});
|
||||
|
||||
updateLocalPane(container);
|
||||
}
|
||||
|
||||
export function updateLocalPane(container) {
|
||||
renderBreadcrumbs(container.querySelector('[data-role="breadcrumbs"]'), state.local.path || "/", navigateLocal);
|
||||
|
||||
const tab = activeTab();
|
||||
const connected = tab?.status === "connected";
|
||||
|
||||
renderFileTable(container.querySelector('[data-role="table"]'), {
|
||||
entries: state.local.entries,
|
||||
selected: state.local.selected,
|
||||
anchor: state.local.selectAnchor,
|
||||
sort: state.local.sort,
|
||||
onSort: (next) => {
|
||||
state.local.sort = next;
|
||||
notify();
|
||||
},
|
||||
onSelect: (names, anchor) => {
|
||||
state.local.selected = names;
|
||||
state.local.selectAnchor = anchor;
|
||||
state.activePane = "local";
|
||||
notify();
|
||||
},
|
||||
onOpen: (name) => openLocalEntry(name),
|
||||
onHoverOpen: (entry) => openLocalEntry(entry.name),
|
||||
dragPayload: (entry) => {
|
||||
if (entry.name === "..") return null;
|
||||
const group =
|
||||
state.local.selected.includes(entry.name) && state.local.selected.length > 1
|
||||
? state.local.entries.filter((e) => state.local.selected.includes(e.name))
|
||||
: [entry];
|
||||
return {
|
||||
source: "local",
|
||||
items: group.map((e) => ({ path: e.path, name: e.name, kind: e.kind })),
|
||||
};
|
||||
},
|
||||
onDropOnEntry: (entry, payload) => handleDropOnLocal(payload, entry.path),
|
||||
contextMenuItems: (entry, selectedEntries) => {
|
||||
if (selectedEntries.length > 1) {
|
||||
const files = selectedEntries.filter((e) => e.kind === "file");
|
||||
return [
|
||||
{
|
||||
label: t("common.delete") + ` (${selectedEntries.length})`,
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
onClick: () => openDeleteLocal(selectedEntries),
|
||||
},
|
||||
...(files.length > 0 && connected
|
||||
? [
|
||||
"sep",
|
||||
{
|
||||
label: t("panels.uploadToServer") + ` (${files.length})`,
|
||||
icon: "upload",
|
||||
onClick: () => queueUploadMany(tab, files),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("common.open"), icon: "folder", onClick: () => openLocalEntry(entry.name) },
|
||||
{ label: t("common.rename"), icon: "pen", onClick: () => openRenameLocal(entry) },
|
||||
{ label: t("common.delete"), icon: "trash", danger: true, onClick: () => openDeleteLocal([entry]) },
|
||||
"sep",
|
||||
...(entry.kind === "dir"
|
||||
? [{ label: t("panels.addToBookmarks"), icon: "star", onClick: () => addBookmark(entry.name, entry.path) }]
|
||||
: []),
|
||||
{ label: t("common.copyPath"), icon: "copy", onClick: () => copyPath(entry.path) },
|
||||
...(entry.kind === "file" && connected
|
||||
? ["sep", { label: t("panels.uploadToServer"), icon: "upload", onClick: () => queueUpload(tab, entry) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const count = state.local.entries.filter((e) => e.name !== "..").length;
|
||||
const totalSize = state.local.entries.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
container.querySelector('[data-role="count"]').textContent = t("panels.countItems", { n: count });
|
||||
container.querySelector('[data-role="size"]').textContent = totalSize > 0 ? `(${formatSize(totalSize)})` : "";
|
||||
}
|
||||
|
||||
function openRenameLocal(entry) {
|
||||
state.opTarget = "local";
|
||||
openRename(entry.name);
|
||||
}
|
||||
function openDeleteLocal(entries) {
|
||||
state.opTarget = "local";
|
||||
openDelete(entries.map((e) => e.name));
|
||||
}
|
||||
function copyPath(path) {
|
||||
navigator.clipboard?.writeText(path);
|
||||
state.statusMessage = t("panels.pathCopied");
|
||||
notify();
|
||||
}
|
||||
158
frontend/js/components/opDialogs.js
Normal file
158
frontend/js/components/opDialogs.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// New Folder / Rename / Delete-confirm modals — shared by local and remote
|
||||
// panes, applied to whichever side is `state.opTarget`.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { doMkdir, doRename, doDelete } from "../fsActions.js";
|
||||
import { escapeHtml as escapeAttr } from "../dom.js";
|
||||
|
||||
let overlayEl = null;
|
||||
let lastSig = null;
|
||||
|
||||
export function openMkdir() {
|
||||
state.dialog = { kind: "mkdir", value: "" };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openRename(name) {
|
||||
state.dialog = { kind: "rename", oldName: name, value: name };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openDelete(names) {
|
||||
// Settings → General → "Confirm before delete" off skips the modal
|
||||
// entirely instead of just pre-answering it.
|
||||
if (!state.settings.confirmBeforeDelete) {
|
||||
doDelete(names);
|
||||
return;
|
||||
}
|
||||
state.dialog = { kind: "delete", names };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function closeDialog() {
|
||||
state.dialog = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const d = state.dialog;
|
||||
if (!d) return;
|
||||
if (d.kind === "mkdir") {
|
||||
const name = d.value.trim();
|
||||
if (!name) return;
|
||||
closeDialog();
|
||||
await doMkdir(name);
|
||||
} else if (d.kind === "rename") {
|
||||
const name = d.value.trim();
|
||||
if (!name || name === d.oldName) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
await doRename(d.oldName, name);
|
||||
} else if (d.kind === "delete") {
|
||||
closeDialog();
|
||||
await doDelete(d.names);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function buildMkdirOrRename(d) {
|
||||
const isRename = d.kind === "rename";
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) closeDialog();
|
||||
});
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog dialog-sm";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup(isRename ? "pen" : "folderAdd", 15)}</span>
|
||||
<span class="dialog-title">${isRename ? t("dialogs.renameTitle") : t("dialogs.newFolderTitle")}</span>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<input class="field-input" data-field="value" type="text" value="${escapeAttr(d.value)}"
|
||||
placeholder="${isRename ? t("dialogs.renamePlaceholder") : t("dialogs.newFolderPlaceholder")}" />
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-accent" data-action="submit">${t("common.ok")}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", closeDialog);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
const input = dialog.querySelector('[data-field="value"]');
|
||||
input.addEventListener("input", () => {
|
||||
state.dialog.value = input.value;
|
||||
});
|
||||
dialog.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
if (isRename) {
|
||||
const dot = input.value.lastIndexOf(".");
|
||||
input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}, 0);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function buildDelete(d) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) closeDialog();
|
||||
});
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog dialog-sm";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
const title =
|
||||
d.names.length === 1
|
||||
? t("dialogs.deleteTitle", { name: escapeAttr(d.names[0]) })
|
||||
: t("dialogs.deleteTitleMulti", { n: d.names.length });
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon text-red">${iconMarkup("dangerTriangle", 15)}</span>
|
||||
<span class="dialog-title">${title}</span>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-text">${t("dialogs.deleteBody")}</p>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-danger" data-action="submit">${t("common.delete")}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", closeDialog);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
export function renderOpDialogs() {
|
||||
const sig = JSON.stringify(
|
||||
state.dialog ? { kind: state.dialog.kind, names: state.dialog.names, oldName: state.dialog.oldName } : null,
|
||||
);
|
||||
if (sig === lastSig) return;
|
||||
lastSig = sig;
|
||||
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
if (!state.dialog) return;
|
||||
|
||||
overlayEl =
|
||||
state.dialog.kind === "delete" ? buildDelete(state.dialog) : buildMkdirOrRename(state.dialog);
|
||||
}
|
||||
319
frontend/js/components/paneLayout.js
Normal file
319
frontend/js/components/paneLayout.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Split-pane layout — tabs dragged from tab bar into panes create splits.
|
||||
// Direction-aware: drop left edge = split left, right edge = split right,
|
||||
// center = replace. Panes can also be reordered by dragging headers.
|
||||
import { state, notify } from "../store.js";
|
||||
import { renderLocalPane, updateLocalPane } from "./localPane.js";
|
||||
import { renderRemotePane, updateRemotePane } from "./remotePane.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { HOVER_OPEN_DELAY, getDraggedTabId } from "../dnd.js";
|
||||
|
||||
const paneRefs = new Map();
|
||||
|
||||
function paneKind(p) { return p.tabId ?? "local"; }
|
||||
function paneLabel(p) { return p.tabId === null ? "Local" : (state.tabs.find((t) => t.id === p.tabId)?.label ?? "?"); }
|
||||
|
||||
/* Render the full pane grid */
|
||||
export function renderPanes(container) {
|
||||
container.innerHTML = "";
|
||||
paneRefs.clear();
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "pane-grid";
|
||||
wrap.style.cssText = `display:grid;grid-template-columns:${buildGrid()};flex:1;min-height:0;`;
|
||||
wrap.addEventListener("dragover", (e) => { e.preventDefault(); });
|
||||
wrap.addEventListener("drop", (e) => {
|
||||
// Drop on empty area (after last pane) → append
|
||||
e.preventDefault();
|
||||
const tabId = e.dataTransfer.getData("tab-id");
|
||||
if (!tabId) return;
|
||||
stopDropHighlight();
|
||||
addPane(parsedTabId(tabId), state.layout.panes.length);
|
||||
});
|
||||
container.appendChild(wrap);
|
||||
|
||||
for (const pane of state.layout.panes) {
|
||||
const cell = createPaneCell(pane);
|
||||
wrap.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function createPaneCell(pane) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "pane-cell";
|
||||
cell.dataset.paneId = pane.id;
|
||||
|
||||
// Edge drop zones
|
||||
const zones = ["left","right","top","bottom","center","swap"].map((z) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = `pane-zone pane-zone--${z}`;
|
||||
d.dataset.zone = z;
|
||||
return d;
|
||||
});
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "pane-header";
|
||||
header.draggable = true;
|
||||
header.dataset.paneId = pane.id;
|
||||
header.innerHTML = `<span class="pane-label">${paneLabel(pane)}</span>`;
|
||||
header.addEventListener("dragstart", onHeaderDragStart);
|
||||
header.addEventListener("dragend", onHeaderDragEnd);
|
||||
|
||||
// Close btn
|
||||
if (state.layout.panes.length > 1) {
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "pane-close-btn";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.innerHTML = iconMarkup("close", 10);
|
||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
|
||||
header.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.className = "pane-body";
|
||||
|
||||
cell.appendChild(header);
|
||||
cell.appendChild(body);
|
||||
zones.forEach((z) => cell.appendChild(z));
|
||||
|
||||
// Drag events on the cell
|
||||
cell.addEventListener("dragover", onPaneDragOver);
|
||||
cell.addEventListener("dragleave", onPaneDragLeave);
|
||||
cell.addEventListener("drop", onPaneDrop);
|
||||
|
||||
// Render file table
|
||||
if (pane.tabId === null) {
|
||||
renderLocalPane(body);
|
||||
} else {
|
||||
const tab = state.tabs.find((t) => t.id === pane.tabId);
|
||||
if (tab) renderRemotePane(body, tab);
|
||||
}
|
||||
paneRefs.set(pane.id, { kind: paneKind(pane), bodyEl: body });
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function buildGrid() {
|
||||
const n = state.layout.panes.length;
|
||||
if (n <= 1) return "1fr";
|
||||
return state.layout.panes.map(() => "1fr").join(" ");
|
||||
}
|
||||
|
||||
/* In-place update (avoids full rebuild on every tick) */
|
||||
export function updatePanes(container) {
|
||||
const wrap = container.querySelector(".pane-grid");
|
||||
if (!wrap) return renderPanes(container);
|
||||
const cells = wrap.querySelectorAll(".pane-cell");
|
||||
if (cells.length !== state.layout.panes.length) return renderPanes(container);
|
||||
const ids = Array.from(cells).map((c) => c.dataset.paneId);
|
||||
const expected = state.layout.panes.map((p) => p.id);
|
||||
if (ids.join(",") !== expected.join(",")) return renderPanes(container);
|
||||
|
||||
wrap.style.gridTemplateColumns = buildGrid();
|
||||
|
||||
for (const pane of state.layout.panes) {
|
||||
const ref = paneRefs.get(pane.id);
|
||||
if (!ref || ref.kind !== paneKind(pane)) return renderPanes(container);
|
||||
const cell = wrap.querySelector(`[data-pane-id="${pane.id}"]`);
|
||||
if (cell) {
|
||||
const label = cell.querySelector(".pane-label");
|
||||
if (label) label.textContent = paneLabel(pane);
|
||||
}
|
||||
if (pane.tabId === null) updateLocalPane(ref.bodyEl);
|
||||
else { const tab = state.tabs.find((t) => t.id === pane.tabId); if (tab) updateRemotePane(ref.bodyEl, tab); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag & Drop: tab bar → pane ─────────────────────────────────────────
|
||||
|
||||
/* Tab bar tabs become draggable — set data "tab-id" on dragstart. Pane cells
|
||||
get visual drop zones (left/right/top/bottom/center), but two rules keep
|
||||
it from feeling random:
|
||||
1. The pane already showing the dragged tab never offers itself as a
|
||||
target -- there's nowhere sensible to put a tab inside its own pane.
|
||||
2. Every other pane needs a beat of sustained hover (HOVER_OPEN_DELAY)
|
||||
before it "opens" as a drop target. Zones appearing the instant the
|
||||
pointer crosses into a pane read as jumpy; the pause makes hovering
|
||||
read as an intentional choice instead. */
|
||||
|
||||
let activeZone = null; // "<paneId>-<zone>" of the currently shown zone overlay
|
||||
let holdTimer = null; // pending "open" timer for the pane below
|
||||
let holdPaneId = null; // pane the hold timer is counting for
|
||||
let openPaneId = null; // pane that passed the hold and is now a live drop target
|
||||
|
||||
function computeZone(cell, clientX, clientY) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
if (x < 0.22) return "left";
|
||||
if (x > 0.78) return "right";
|
||||
if (y < 0.22) return "top";
|
||||
if (y > 0.78) return "bottom";
|
||||
return "center";
|
||||
}
|
||||
|
||||
function showZone(cell, zone) {
|
||||
const key = `${cell.dataset.paneId}-${zone}`;
|
||||
if (activeZone === key) return;
|
||||
document.querySelectorAll(".pane-cell.drop-active").forEach((c) => {
|
||||
if (c !== cell) {
|
||||
c.classList.remove("drop-active");
|
||||
delete c.dataset.dropZone;
|
||||
}
|
||||
});
|
||||
activeZone = key;
|
||||
cell.classList.add("drop-active");
|
||||
cell.dataset.dropZone = zone;
|
||||
}
|
||||
|
||||
function clearHold() {
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
holdPaneId = null;
|
||||
}
|
||||
|
||||
function stopDropHighlight() {
|
||||
document.querySelectorAll(".pane-cell").forEach((c) => {
|
||||
c.classList.remove("drop-active", "drop-pending");
|
||||
delete c.dataset.dropZone;
|
||||
});
|
||||
activeZone = null;
|
||||
}
|
||||
|
||||
function resetPaneDrag() {
|
||||
clearHold();
|
||||
openPaneId = null;
|
||||
stopDropHighlight();
|
||||
}
|
||||
|
||||
function onPaneDragOver(e) {
|
||||
if (!e.dataTransfer.types.includes("tab-id")) return;
|
||||
const cell = e.currentTarget;
|
||||
const paneId = cell.dataset.paneId;
|
||||
const pane = state.layout.panes.find((p) => p.id === paneId);
|
||||
|
||||
// Dragging a tab over the pane that already shows it -- never a valid
|
||||
// target. Stop the event here so it doesn't bubble to the grid wrap's
|
||||
// unconditional preventDefault(), which would otherwise still allow a
|
||||
// drop (routed to the "append at end" handler) at this pointer position.
|
||||
if (pane && pane.tabId === getDraggedTabId()) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
if (openPaneId === paneId) {
|
||||
showZone(cell, computeZone(cell, e.clientX, e.clientY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (holdPaneId !== paneId) {
|
||||
clearHold();
|
||||
holdPaneId = paneId;
|
||||
cell.classList.add("drop-pending");
|
||||
holdTimer = setTimeout(() => {
|
||||
holdTimer = null;
|
||||
cell.classList.remove("drop-pending");
|
||||
openPaneId = paneId;
|
||||
showZone(cell, computeZone(cell, e.clientX, e.clientY));
|
||||
}, HOVER_OPEN_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneDragLeave(e) {
|
||||
const cell = e.currentTarget;
|
||||
if (cell.contains(e.relatedTarget)) return;
|
||||
cell.classList.remove("drop-pending");
|
||||
if (holdPaneId === cell.dataset.paneId) clearHold();
|
||||
if (openPaneId === cell.dataset.paneId) {
|
||||
openPaneId = null;
|
||||
cell.classList.remove("drop-active");
|
||||
delete cell.dataset.dropZone;
|
||||
activeZone = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tabId = e.dataTransfer.getData("tab-id");
|
||||
const cell = e.currentTarget;
|
||||
const paneId = cell.dataset.paneId;
|
||||
const wasOpen = openPaneId === paneId;
|
||||
const zone = cell.dataset.dropZone || "center";
|
||||
resetPaneDrag();
|
||||
if (!tabId || !wasOpen) return; // released before this pane "opened" -- ignore
|
||||
|
||||
const idx = state.layout.panes.findIndex((p) => p.id === paneId);
|
||||
if (idx === -1) return;
|
||||
const tid = parsedTabId(tabId);
|
||||
|
||||
switch (zone) {
|
||||
case "left": addPane(tid, idx); break;
|
||||
case "right": addPane(tid, idx + 1); break;
|
||||
case "top": addPane(tid, idx + 1); break; // in flat layout, left/right suffices
|
||||
case "bottom": addPane(tid, idx + 1); break; // top/bottom = same as right
|
||||
case "center": // replace this pane (but don't add duplicate)
|
||||
if (!state.layout.panes.some((p) => p.tabId === tid && p.id !== paneId)) {
|
||||
state.layout.panes[idx].tabId = tid;
|
||||
}
|
||||
break;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// A drag can end (dropped, cancelled, Escaped) outside every pane cell --
|
||||
// clean up hold/zone state unconditionally so it never gets stuck.
|
||||
window.addEventListener("dragend", resetPaneDrag);
|
||||
|
||||
// ── Header drag (reorder) ───────────────────────────────────────────────
|
||||
|
||||
let headerDragId = null;
|
||||
function onHeaderDragStart(e) {
|
||||
headerDragId = e.currentTarget.dataset.paneId;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", headerDragId);
|
||||
e.target.closest(".pane-cell")?.classList.add("pane-dragging");
|
||||
}
|
||||
function onHeaderDragEnd(e) {
|
||||
e.target.closest(".pane-cell")?.classList.remove("pane-dragging");
|
||||
stopDropHighlight();
|
||||
headerDragId = null;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parsedTabId(raw) {
|
||||
return raw === "local" ? null : raw;
|
||||
}
|
||||
|
||||
function addPane(tabId, index) {
|
||||
// If this tab is already in another pane, remove it
|
||||
const existing = state.layout.panes.findIndex((p) => p.tabId === tabId);
|
||||
if (existing !== -1) {
|
||||
state.layout.panes.splice(existing, 1);
|
||||
if (existing < index) index--;
|
||||
}
|
||||
state.layout.panes.splice(index, 0, { id: `pane-${Date.now()}`, tabId });
|
||||
}
|
||||
|
||||
function closePane(paneId) {
|
||||
if (state.layout.panes.length <= 1) return;
|
||||
state.layout.panes = state.layout.panes.filter((p) => p.id !== paneId);
|
||||
notify();
|
||||
}
|
||||
|
||||
// ── Public API: open a tab in a pane from outside ───────────────────────
|
||||
export function openInPane(tabId) {
|
||||
// If already visible, do nothing (it's already in some pane)
|
||||
if (state.layout.panes.some((p) => p.tabId === tabId)) return;
|
||||
|
||||
// Replace the first pane (single-pane UX — click = switch, not split)
|
||||
// Splitting only happens via drag-and-drop into edge zones.
|
||||
state.layout.panes[0].tabId = tabId;
|
||||
notify();
|
||||
}
|
||||
130
frontend/js/components/queuePanel.js
Normal file
130
frontend/js/components/queuePanel.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize, taskStateKind, taskStateDetail, progressPct } from "../format.js";
|
||||
import * as ipc from "../ipc.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
const STATE_LABEL_KEY = {
|
||||
queued: "queue.stateQueued",
|
||||
completed: "queue.stateComplete",
|
||||
failed: "queue.stateFailed",
|
||||
paused: "queue.statePaused",
|
||||
cancelled: "queue.stateCancelled",
|
||||
retrying: "queue.stateRetrying",
|
||||
};
|
||||
|
||||
function stateBadge(task) {
|
||||
const kind = taskStateKind(task.state);
|
||||
if (kind === "running") {
|
||||
if (task.speed) return { text: `${formatSize(task.speed)}/s`, cls: "text-green" };
|
||||
const verb = task.kind === "upload" ? "queue.stateUploading" : "queue.stateDownloading";
|
||||
return { text: t(verb), cls: "text-accent" };
|
||||
}
|
||||
const cls = { completed: "text-green", failed: "text-red", paused: "text-yellow" }[kind] ?? "text-hint";
|
||||
return { text: t(STATE_LABEL_KEY[kind] ?? "queue.stateQueued"), cls };
|
||||
}
|
||||
|
||||
function taskRow(task) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "queue-task";
|
||||
const kind = taskStateKind(task.state);
|
||||
const pct = progressPct(task);
|
||||
const badge = stateBadge(task);
|
||||
const err = kind === "failed" ? taskStateDetail(task.state) : "";
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="icon icon--important">${iconMarkup(task.kind === "upload" ? "upload" : "download", 15)}</span>
|
||||
<span class="queue-task-name" ${err ? `title="${escapeAttr(err)}"` : ""}>${escapeHtml(task.fileName)}</span>
|
||||
<div class="progress"><div class="progress-fill ${kind}" style="width:${pct}%"></div></div>
|
||||
<span class="queue-task-pct mono">${pct.toFixed(0)}%</span>
|
||||
<span class="queue-task-state ${badge.cls}">${badge.text}</span>
|
||||
<div class="queue-task-actions" data-role="actions"></div>
|
||||
`;
|
||||
|
||||
const actions = row.querySelector('[data-role="actions"]');
|
||||
if (kind === "running") {
|
||||
actions.appendChild(iconActionBtn("pauseCircle", () => ipc.pauseTask(task.id)));
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.cancelTask(task.id)));
|
||||
} else if (kind === "paused" || kind === "queued") {
|
||||
actions.appendChild(iconActionBtn("playCircle", () => ipc.resumeTask(task.id)));
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.cancelTask(task.id)));
|
||||
} else {
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.removeTask(task.id)));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function iconActionBtn(icon, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn icon-btn btn-ghost";
|
||||
btn.style.width = "22px";
|
||||
btn.style.height = "22px";
|
||||
btn.innerHTML = iconMarkup(icon, 13);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
export function renderQueuePanel(container) {
|
||||
const tasks = state.queueTasks;
|
||||
const pending = tasks.filter((tk) => ["running", "queued"].includes(taskStateKind(tk.state)));
|
||||
const n = pending.length > 0 ? pending.length : tasks.length;
|
||||
const expanded = state.showQueue && tasks.length > 0;
|
||||
|
||||
container.classList.toggle("expanded", expanded);
|
||||
|
||||
const aggSpeed = tasks
|
||||
.filter((tk) => taskStateKind(tk.state) === "running")
|
||||
.reduce((sum, tk) => sum + (tk.speed ?? 0), 0);
|
||||
const done = tasks.filter((tk) => taskStateKind(tk.state) === "completed").length;
|
||||
const failed = tasks.filter((tk) => taskStateKind(tk.state) === "failed").length;
|
||||
|
||||
let rightHtml = "";
|
||||
if (aggSpeed > 0) {
|
||||
rightHtml = `<span class="text-green mono">${formatSize(aggSpeed)}/s</span>`;
|
||||
} else if (tasks.length > 0) {
|
||||
rightHtml = `<span class="text-dim">${t("queue.doneSuffix", { done, total: tasks.length })}</span>`;
|
||||
if (failed > 0) rightHtml += `<span class="text-red" style="margin-left:8px">${t("queue.failedSuffix", { n: failed })}</span>`;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="queue-header" data-role="header">
|
||||
<span class="icon">${iconMarkup(state.showQueue ? "altArrowDown" : "altArrowUp", 13)}</span>
|
||||
<span class="queue-header-title">${t("queue.title", { n })}</span>
|
||||
<div class="queue-header-right">${rightHtml}</div>
|
||||
</div>
|
||||
<div class="queue-list" data-role="list"></div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-role="header"]').addEventListener("click", () => {
|
||||
state.showQueue = !state.showQueue;
|
||||
notify();
|
||||
});
|
||||
|
||||
const list = container.querySelector('[data-role="list"]');
|
||||
if (tasks.length === 0) {
|
||||
list.innerHTML = `<div class="popover-empty">${t("queue.empty")}</div>`;
|
||||
} else {
|
||||
for (const task of tasks) list.appendChild(taskRow(task));
|
||||
}
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.showQueue,
|
||||
state.queueTasks.map((tk) => [tk.id, tk.transferredBytes, tk.speed, JSON.stringify(tk.state)]),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateQueuePanelIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderQueuePanel(container);
|
||||
}
|
||||
|
||||
const escapeAttr = escapeHtml;
|
||||
145
frontend/js/components/remotePane.js
Normal file
145
frontend/js/components/remotePane.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize } from "../format.js";
|
||||
import { renderFileTable } from "./fileTable.js";
|
||||
import { renderBreadcrumbs } from "./breadcrumbs.js";
|
||||
import { makeDropTarget } from "../dnd.js";
|
||||
import { openRename, openDelete } from "./opDialogs.js";
|
||||
import {
|
||||
navigateRemote,
|
||||
openRemoteEntry,
|
||||
handleDropOnRemote,
|
||||
refreshRemote,
|
||||
queueDownload,
|
||||
queueDownloadMany,
|
||||
} from "../fsActions.js";
|
||||
|
||||
const STATUS_ICON = { connected: "●", connecting: "◐", error: "×", disconnected: "○" };
|
||||
const STATUS_CLASS = { connected: "connected", connecting: "connecting", error: "error", disconnected: "disconnected" };
|
||||
|
||||
export function renderRemotePane(container, tab) {
|
||||
container.innerHTML = `
|
||||
<div class="path-bar">
|
||||
<span class="path-bar-label">${t("panels.remoteLabel", { host: tab.params.host })}</span>
|
||||
<span class="status-dot ${STATUS_CLASS[tab.status] ?? "disconnected"}">${STATUS_ICON[tab.status] ?? "○"}</span>
|
||||
<div class="breadcrumbs" data-role="breadcrumbs"></div>
|
||||
<div class="path-bar-right" data-role="loading"></div>
|
||||
</div>
|
||||
<div data-role="table" class="file-table-wrap"></div>
|
||||
<div class="pane-footer">
|
||||
<span data-role="count" class="text-dim" style="font-size:11px"></span>
|
||||
<span data-role="size" class="text-hint" style="font-size:11px"></span>
|
||||
<div class="pane-footer-right">
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="refresh" title="${t("common.refresh")}">${iconMarkup("refresh", 13)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="refresh"]').addEventListener("click", () => {
|
||||
if (!tab.loading) refreshRemote(tab);
|
||||
});
|
||||
|
||||
makeDropTarget(container, {
|
||||
onDrop: (payload) => handleDropOnRemote(tab, payload, tab.remotePath),
|
||||
onHoverChange: (hover) => container.classList.toggle("drag-over", hover),
|
||||
});
|
||||
|
||||
updateRemotePane(container, tab);
|
||||
}
|
||||
|
||||
export function updateRemotePane(container, tab) {
|
||||
renderBreadcrumbs(container.querySelector('[data-role="breadcrumbs"]'), tab.remotePath || "/", (path) =>
|
||||
navigateRemote(tab, path),
|
||||
);
|
||||
|
||||
container.querySelector('[data-role="loading"]').innerHTML = tab.loading
|
||||
? `<span>${t("panels.loading")}</span><span class="spinner"></span>`
|
||||
: "";
|
||||
|
||||
renderFileTable(container.querySelector('[data-role="table"]'), {
|
||||
entries: tab.remoteEntries,
|
||||
selected: tab.remoteSelected,
|
||||
anchor: tab.remoteSelectAnchor,
|
||||
sort: tab.sort,
|
||||
onSort: (next) => {
|
||||
tab.sort = next;
|
||||
notify();
|
||||
},
|
||||
onSelect: (names, anchor) => {
|
||||
tab.remoteSelected = names;
|
||||
tab.remoteSelectAnchor = anchor;
|
||||
state.activePane = "remote";
|
||||
notify();
|
||||
},
|
||||
onOpen: (name) => openRemoteEntry(tab, name),
|
||||
onHoverOpen: (entry) => openRemoteEntry(tab, entry.name),
|
||||
dragPayload: (entry) => {
|
||||
if (entry.name === ".." || tab.status !== "connected") return null;
|
||||
const group =
|
||||
tab.remoteSelected.includes(entry.name) && tab.remoteSelected.length > 1
|
||||
? tab.remoteEntries.filter((e) => tab.remoteSelected.includes(e.name))
|
||||
: [entry];
|
||||
return {
|
||||
source: "remote",
|
||||
connectionId: tab.id,
|
||||
items: group.map((e) => ({ path: e.path, name: e.name, kind: e.kind })),
|
||||
};
|
||||
},
|
||||
onDropOnEntry: (entry, payload) => handleDropOnRemote(tab, payload, entry.path),
|
||||
contextMenuItems: (entry, selectedEntries) => {
|
||||
if (selectedEntries.length > 1) {
|
||||
const files = selectedEntries.filter((e) => e.kind === "file");
|
||||
return [
|
||||
{
|
||||
label: t("common.delete") + ` (${selectedEntries.length})`,
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
onClick: () => openDeleteRemote(selectedEntries),
|
||||
},
|
||||
...(files.length > 0
|
||||
? [
|
||||
"sep",
|
||||
{
|
||||
label: t("panels.downloadToLocal") + ` (${files.length})`,
|
||||
icon: "download",
|
||||
onClick: () => queueDownloadMany(tab, files),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("common.open"), icon: "folder", onClick: () => openRemoteEntry(tab, entry.name) },
|
||||
{ label: t("common.rename"), icon: "pen", onClick: () => openRenameRemote(entry) },
|
||||
{ label: t("common.delete"), icon: "trash", danger: true, onClick: () => openDeleteRemote([entry]) },
|
||||
"sep",
|
||||
{ label: t("common.copyPath"), icon: "copy", onClick: () => copyPath(entry.path) },
|
||||
...(entry.kind === "file"
|
||||
? [{ label: t("panels.downloadToLocal"), icon: "download", onClick: () => queueDownload(tab, entry) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const count = tab.remoteEntries.filter((e) => e.name !== "..").length;
|
||||
const totalSize = tab.remoteEntries.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
container.querySelector('[data-role="count"]').textContent = tab.loading
|
||||
? t("panels.loading")
|
||||
: t("panels.countItems", { n: count });
|
||||
container.querySelector('[data-role="size"]').textContent = totalSize > 0 ? `(${formatSize(totalSize)})` : "";
|
||||
}
|
||||
|
||||
function openRenameRemote(entry) {
|
||||
state.opTarget = "remote";
|
||||
openRename(entry.name);
|
||||
}
|
||||
function openDeleteRemote(entries) {
|
||||
state.opTarget = "remote";
|
||||
openDelete(entries.map((e) => e.name));
|
||||
}
|
||||
function copyPath(path) {
|
||||
navigator.clipboard?.writeText(path);
|
||||
state.statusMessage = t("panels.pathCopied");
|
||||
notify();
|
||||
}
|
||||
496
frontend/js/components/settingsDialog.js
Normal file
496
frontend/js/components/settingsDialog.js
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
// Settings dialog — nav list left, section content right. Mirrors the old
|
||||
// src/ui/app/settings/{general,connections,transfers,about}.rs sections
|
||||
// (Security folded in here too). Unlike connectDialog/opDialogs there are no
|
||||
// free-text inputs in here, so the whole content pane can just be rebuilt
|
||||
// from scratch on every render -- no focus/caret state to preserve.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import {
|
||||
setConfirmBeforeDelete,
|
||||
useCurrentFolderAsDefault,
|
||||
resetDefaultFolder,
|
||||
setMaxConcurrent,
|
||||
setAutoClearCompletedSecs,
|
||||
togglePanel,
|
||||
clearHistory,
|
||||
deleteSite,
|
||||
forgetPassword,
|
||||
forgetAllPasswords,
|
||||
revealDatabase,
|
||||
setLanguage,
|
||||
setTheme,
|
||||
} from "../settingsActions.js";
|
||||
|
||||
const LANG_OPTIONS = [
|
||||
["en", "English"],
|
||||
["ru", "Русский"],
|
||||
["es", "Español"],
|
||||
["fr", "Français"],
|
||||
["de", "Deutsch"],
|
||||
["it", "Italiano"],
|
||||
["pt", "Português"],
|
||||
["pl", "Polski"],
|
||||
["zh", "简体中文"],
|
||||
["ja", "日本語"],
|
||||
["ko", "한국어"],
|
||||
["tr", "Türkçe"],
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "general", icon: "settings", labelKey: "settings.navGeneral" },
|
||||
{ id: "appearance", icon: "monitor", labelKey: "settings.navAppearance" },
|
||||
{ id: "connections", icon: "serverSquare", labelKey: "settings.navConnections" },
|
||||
{ id: "history", icon: "history", labelKey: "settings.navHistory" },
|
||||
{ id: "security", icon: "shieldKeyhole", labelKey: "settings.navSecurity" },
|
||||
{ id: "transfers", icon: "clock", labelKey: "settings.navTransfers" },
|
||||
{ id: "about", icon: "documentText", labelKey: "settings.navAbout" },
|
||||
];
|
||||
|
||||
let overlayEl = null;
|
||||
let activeSection = "general";
|
||||
let historyClearConfirm = false;
|
||||
|
||||
export function openSettings() {
|
||||
activeSection = "general";
|
||||
historyClearConfirm = false;
|
||||
state.showSettingsDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.showSettingsDialog = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
function switchSection(id) {
|
||||
activeSection = id;
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
renderNav();
|
||||
}
|
||||
|
||||
// A plain <span>, not a <button> -- a labelable descendant (button, input)
|
||||
// inside a clickable wrapper gets a second, forwarded click event from the
|
||||
// browser itself on top of the one the wrapper's own listener already
|
||||
// handles, which silently double-toggles state right back to where it
|
||||
// started. Since the wrapper handles the click, the indicator only needs to
|
||||
// be visual.
|
||||
function switchToggle(active) {
|
||||
return `<span class="switch ${active ? "on" : ""}" data-role="switch"></span>`;
|
||||
}
|
||||
|
||||
// --- section renderers -------------------------------------------------------
|
||||
|
||||
function sectionGeneral() {
|
||||
const folder = state.settings.defaultLocalFolder || t("settings.homeDirectory");
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navGeneral")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-check-row" data-action="toggle-confirm-delete">
|
||||
${switchToggle(state.settings.confirmBeforeDelete)}
|
||||
<div>
|
||||
<div class="settings-label">${t("settings.confirmDelete")}</div>
|
||||
<div class="settings-hint">${t("settings.confirmDeleteHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.language")}</div>
|
||||
<div class="settings-hint">${t("settings.languageHint")}</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<select class="settings-select" data-action="set-language">
|
||||
${LANG_OPTIONS.map(([code, native]) =>
|
||||
`<option value="${code}" ${code === state.settings.language ? "selected" : ""}>${native}</option>`
|
||||
).join("")}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.startupFolder")}</div>
|
||||
<div class="settings-hint">${t("settings.startupFolderHint")}</div>
|
||||
<div class="settings-hint mono" style="margin-top:8px;color:var(--text-dim)">${escapeHtml(folder)}</div>
|
||||
<div class="settings-row" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-ghost" data-action="use-current-folder">${t("settings.useCurrentFolder")}</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="reset-folder">${t("settings.resetToHome")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionAppearance() {
|
||||
const currentTheme = state.settings.theme;
|
||||
const themes = [
|
||||
["ember", "settings.themeEmber"],
|
||||
["frost", "settings.themeFrost"],
|
||||
["midnight", "settings.themeMidnight"],
|
||||
["forest", "settings.themeForest"],
|
||||
["dawn", "settings.themeDawn"],
|
||||
["ash", "settings.themeAsh"],
|
||||
];
|
||||
|
||||
const toggles = [
|
||||
["showToolbar", "settings.toolbar"],
|
||||
["showSidebar", "settings.sidebar"],
|
||||
["showStatusBar", "settings.statusBar"],
|
||||
["showQueuePanel", "settings.transferQueue"],
|
||||
];
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navAppearance")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.theme")}</div>
|
||||
<div class="settings-hint" style="margin-bottom:10px">${t("settings.themeHint")}</div>
|
||||
<div class="theme-grid" data-action="set-theme">
|
||||
${themes
|
||||
.map(
|
||||
([id, labelKey]) => `
|
||||
<button type="button" class="theme-card ${id === currentTheme ? "active" : ""}" data-theme-id="${id}">
|
||||
<div class="theme-swatch theme-swatch--${id}"></div>
|
||||
<span class="theme-card-label">${t(labelKey)}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
${toggles
|
||||
.map(
|
||||
([key, labelKey]) => `
|
||||
<div class="settings-check-row" style="margin-bottom:10px" data-panel-toggle="${key}">
|
||||
${switchToggle(state.settings[key])}
|
||||
<div class="settings-label">${t(labelKey)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionConnections() {
|
||||
const sites = state.sites
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete-site" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const bookmarks = state.sidebar.bookmarks
|
||||
.map(
|
||||
(b) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(b.name)}</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(b.path)}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete-bookmark" data-id="${b.id}" title="${t("panels.sidebarRemoveBookmark")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.sites")}</div>
|
||||
<div class="settings-block">
|
||||
${state.sites.length ? sites : `<div class="settings-empty">${t("settings.noSites")}</div>`}
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.bookmarksTitle")}</div>
|
||||
${state.sidebar.bookmarks.length ? bookmarks : `<div class="settings-empty">${t("settings.noBookmarks")}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionHistory() {
|
||||
const rows = state.history
|
||||
.map(
|
||||
(h) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(h.username)}@${escapeHtml(h.host)}:${h.port}</div>
|
||||
</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(h.connectedAt)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const footer = state.history.length
|
||||
? historyClearConfirm
|
||||
? `
|
||||
<div class="settings-hint" style="margin-top:12px">${t("settings.confirmClear")}</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<button type="button" class="btn btn-danger" data-action="confirm-clear-history">${t("settings.deleteAll")}</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="cancel-clear-history">${t("common.cancel")}</button>
|
||||
</div>`
|
||||
: `<button type="button" class="btn btn-ghost" style="margin-top:12px;color:var(--red)" data-action="clear-history">${t("settings.clearAll")}</button>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.historyTitle")}</div>
|
||||
<div class="settings-block">
|
||||
${state.history.length ? rows : `<div class="settings-empty">${t("settings.historyEmpty")}</div>`}
|
||||
${footer}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionSecurity() {
|
||||
const rows = state.sites
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="forget-password" data-id="${s.id}" title="${t("settings.forgetPasswordHover")}">${iconMarkup("lockPassword", 13)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navSecurity")}</div>
|
||||
<div class="settings-hint">${t("settings.securityHint")}</div>
|
||||
<div class="settings-block">
|
||||
${state.sites.length ? rows : `<div class="settings-empty">${t("settings.noConnections")}</div>`}
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<button type="button" class="btn btn-ghost" style="color:var(--red)" data-action="forget-all">${t("settings.forgetAll")}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function segmented(options, current, action) {
|
||||
return `
|
||||
<div class="segmented" data-action="${action}">
|
||||
${options
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<button type="button" class="segmented-item ${value === current ? "active" : ""}" data-value="${value}">${label}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionTransfers() {
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navTransfers")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.concurrentTitle")}</div>
|
||||
<div class="settings-hint">${t("settings.concurrentHint")}</div>
|
||||
<div style="margin-top:8px;width:220px">
|
||||
${segmented(
|
||||
[1, 2, 3, 4, 5, 6].map((n) => [String(n), n]),
|
||||
state.settings.maxConcurrent,
|
||||
"set-max-concurrent",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.autoRemoveTitle")}</div>
|
||||
<div class="settings-hint">${t("settings.autoRemoveHint")}</div>
|
||||
<div style="margin-top:8px;width:260px">
|
||||
${segmented(
|
||||
[
|
||||
[t("settings.never"), 0],
|
||||
[t("settings.tenSec"), 10],
|
||||
[t("settings.thirtySec"), 30],
|
||||
[t("settings.oneMin"), 60],
|
||||
],
|
||||
state.settings.autoClearCompletedSecs,
|
||||
"set-auto-clear",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionAbout() {
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navAbout")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-value-row"><span class="k">${t("settings.version")}</span><span class="v">0.1.0</span></div>
|
||||
<div class="settings-value-row"><span class="k">${t("settings.license")}</span><span class="v">GPL-3.0-only</span></div>
|
||||
<div class="settings-value-row"><span class="k">${t("settings.database")}</span><span class="v" data-role="db-path">…</span></div>
|
||||
<button type="button" class="btn btn-ghost" style="margin-top:10px" data-action="reveal-db">${t("settings.revealInFolder")}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const RENDERERS = {
|
||||
general: sectionGeneral,
|
||||
appearance: sectionAppearance,
|
||||
connections: sectionConnections,
|
||||
history: sectionHistory,
|
||||
security: sectionSecurity,
|
||||
transfers: sectionTransfers,
|
||||
about: sectionAbout,
|
||||
};
|
||||
|
||||
// --- shell + wiring ------------------------------------------------------------
|
||||
|
||||
function build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog dialog-settings";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup("settings", 16)}</span>
|
||||
<span class="dialog-title">${t("settings.title")}</span>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-nav" data-role="nav"></div>
|
||||
<div class="settings-content" data-role="content"></div>
|
||||
</div>
|
||||
`;
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", close);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = overlayEl.querySelector('[data-role="nav"]');
|
||||
nav.innerHTML = SECTIONS.map(
|
||||
(s) => `
|
||||
<button type="button" class="settings-nav-item ${s.id === activeSection ? "active" : ""}" data-section="${s.id}">
|
||||
${iconMarkup(s.icon, 14)}<span>${t(s.labelKey)}</span>
|
||||
</button>`,
|
||||
).join("");
|
||||
nav.querySelectorAll("[data-section]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => switchSection(btn.dataset.section));
|
||||
});
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const content = overlayEl.querySelector('[data-role="content"]');
|
||||
content.innerHTML = RENDERERS[activeSection]();
|
||||
wireContentActions(content);
|
||||
}
|
||||
|
||||
function wireContentActions(root) {
|
||||
root.querySelector("[data-action='toggle-confirm-delete']")?.addEventListener("click", () => {
|
||||
setConfirmBeforeDelete(!state.settings.confirmBeforeDelete);
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='use-current-folder']")?.addEventListener("click", async () => {
|
||||
await useCurrentFolderAsDefault();
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='reset-folder']")?.addEventListener("click", async () => {
|
||||
await resetDefaultFolder();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='set-theme'] .theme-card").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
setTheme(el.dataset.themeId);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-panel-toggle]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
togglePanel(el.dataset.panelToggle);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
await deleteSite(btn.dataset.id);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-action='delete-bookmark']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const { removeBookmark } = await import("../fsActions.js");
|
||||
await removeBookmark(Number(btn.dataset.id));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-action='clear-history']")?.addEventListener("click", () => {
|
||||
historyClearConfirm = true;
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='cancel-clear-history']")?.addEventListener("click", () => {
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='confirm-clear-history']")?.addEventListener("click", async () => {
|
||||
await clearHistory();
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='forget-password']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => forgetPassword(btn.dataset.id));
|
||||
});
|
||||
root.querySelector("[data-action='forget-all']")?.addEventListener("click", forgetAllPasswords);
|
||||
|
||||
root.querySelectorAll("[data-action='set-max-concurrent'] .segmented-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setMaxConcurrent(Number(btn.dataset.value));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-action='set-auto-clear'] .segmented-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setAutoClearCompletedSecs(Number(btn.dataset.value));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-action='reveal-db']")?.addEventListener("click", revealDatabase);
|
||||
|
||||
root.querySelector("[data-action='set-language']")?.addEventListener("change", (e) => {
|
||||
setLanguage(e.target.value);
|
||||
// Re-render everything so all strings update immediately
|
||||
renderNav();
|
||||
renderContent();
|
||||
// After re-render, re-select the correct option
|
||||
const sel = overlayEl.querySelector("[data-action='set-language']");
|
||||
if (sel) sel.value = state.settings.language;
|
||||
});
|
||||
|
||||
const dbPathEl = root.querySelector('[data-role="db-path"]');
|
||||
if (dbPathEl) {
|
||||
import("../ipc.js").then((ipc) =>
|
||||
ipc.appDataDir().then((dir) => {
|
||||
dbPathEl.textContent = `${dir}/wherry.db`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let wasOpen = false;
|
||||
|
||||
export function renderSettingsDialog() {
|
||||
if (!state.showSettingsDialog) {
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
wasOpen = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen) {
|
||||
overlayEl = build();
|
||||
renderNav();
|
||||
renderContent();
|
||||
wasOpen = true;
|
||||
}
|
||||
}
|
||||
96
frontend/js/components/sidebar.js
Normal file
96
frontend/js/components/sidebar.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup, QUICK_ACCESS_ICONS } from "../icons.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { navigateLocal, removeBookmark } from "../fsActions.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
function item(icon, label, active, onClick) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `row ${active ? "active" : ""}`;
|
||||
row.innerHTML = `<span class="icon">${iconMarkup(icon, 15)}</span><span style="margin-left:8px;font-size:12.5px">${label}</span>`;
|
||||
row.addEventListener("click", onClick);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function renderSidebar(container) {
|
||||
container.innerHTML = "";
|
||||
|
||||
const logo = document.createElement("div");
|
||||
logo.className = "sidebar-logo";
|
||||
logo.innerHTML = `<span class="sidebar-logo-badge">W</span><span class="sidebar-logo-text">Wherry</span>`;
|
||||
container.appendChild(logo);
|
||||
|
||||
const qaTitle = document.createElement("div");
|
||||
qaTitle.className = "sidebar-section-title";
|
||||
qaTitle.textContent = t("panels.localLabel");
|
||||
container.appendChild(qaTitle);
|
||||
|
||||
for (const qa of state.sidebar.quickAccess) {
|
||||
if (!qa.path) continue;
|
||||
const active = state.local.path === qa.path;
|
||||
container.appendChild(
|
||||
item(QUICK_ACCESS_ICONS[qa.name] ?? "folder", qa.name, active, () => navigateLocal(qa.path)),
|
||||
);
|
||||
}
|
||||
|
||||
const divider1 = document.createElement("div");
|
||||
divider1.className = "sidebar-divider";
|
||||
container.appendChild(divider1);
|
||||
|
||||
const drivesTitle = document.createElement("div");
|
||||
drivesTitle.className = "sidebar-section-title";
|
||||
drivesTitle.textContent = t("panels.sidebarDrives");
|
||||
container.appendChild(drivesTitle);
|
||||
|
||||
container.appendChild(
|
||||
item("ssd", t("panels.sidebarRoot"), state.local.path === "/", () => navigateLocal("/")),
|
||||
);
|
||||
|
||||
if (state.sidebar.bookmarks.length > 0) {
|
||||
const divider2 = document.createElement("div");
|
||||
divider2.className = "sidebar-divider";
|
||||
container.appendChild(divider2);
|
||||
|
||||
const bmTitle = document.createElement("div");
|
||||
bmTitle.className = "sidebar-section-title";
|
||||
bmTitle.textContent = t("panels.sidebarBookmarks");
|
||||
container.appendChild(bmTitle);
|
||||
|
||||
for (const bm of state.sidebar.bookmarks) {
|
||||
const row = item("star", bm.name, state.local.path === bm.path, () => navigateLocal(bm.path));
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, [
|
||||
{ label: t("panels.sidebarRemoveBookmark"), icon: "trash", danger: true, onClick: () => removeBookmark(bm.id) },
|
||||
]);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "sidebar-spacer";
|
||||
container.appendChild(spacer);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
footer.textContent = "Wherry v0.1";
|
||||
container.appendChild(footer);
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.local.path,
|
||||
state.sidebar.bookmarks.map((b) => `${b.id}:${b.path}`),
|
||||
state.sidebar.quickAccess.map((q) => q.path),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateSidebarIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderSidebar(container);
|
||||
}
|
||||
43
frontend/js/components/statusBar.js
Normal file
43
frontend/js/components/statusBar.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize, taskStateKind } from "../format.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
export function renderStatusBar(container) {
|
||||
const aggSpeed = state.queueTasks
|
||||
.filter((tk) => taskStateKind(tk.state) === "running")
|
||||
.reduce((sum, tk) => sum + (tk.speed ?? 0), 0);
|
||||
|
||||
const connected = state.tabs.filter((tb) => tb.status === "connected").length;
|
||||
const total = state.tabs.length;
|
||||
|
||||
container.innerHTML = `
|
||||
<span data-role="status">${escapeHtml(state.statusMessage)}</span>
|
||||
<div class="status-bar-right">
|
||||
${aggSpeed > 0 ? `<span class="text-green mono">${formatSize(aggSpeed)}/s</span>` : ""}
|
||||
${
|
||||
total > 0
|
||||
? `<span class="${connected > 0 ? "text-green" : "text-hint"}">${connected > 0 ? iconMarkup("lockPassword", 11) : ""} ${connected}/${total}</span>`
|
||||
: `<span class="text-hint">${t("status.notConnected")}</span>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.statusMessage,
|
||||
state.tabs.map((tb) => tb.status),
|
||||
state.queueTasks.map((tk) => tk.speed),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateStatusBarIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderStatusBar(container);
|
||||
}
|
||||
80
frontend/js/components/tabs.js
Normal file
80
frontend/js/components/tabs.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { closeTab } from "../fsActions.js";
|
||||
import { openInPane } from "./paneLayout.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { setDraggedTabId } from "../dnd.js";
|
||||
|
||||
const STATUS_COLOR = { connected:"text-green", connecting:"text-yellow", error:"text-red", disconnected:"text-hint" };
|
||||
const STATUS_DOT = { connected:"●", connecting:"◐", error:"×", disconnected:"○" };
|
||||
|
||||
export function renderTabBar(container) {
|
||||
container.innerHTML = "";
|
||||
|
||||
// Local tab — always present, not closable
|
||||
const localTab = createTab("local", "Local", null, false);
|
||||
container.appendChild(localTab);
|
||||
|
||||
for (const tab of state.tabs) {
|
||||
const el = createTab(tab.id, tab.label, tab.status, true);
|
||||
container.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function createTab(id, label, status, closable) {
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "tab";
|
||||
el.draggable = true;
|
||||
|
||||
const inLayout = state.layout.panes.some((p) => p.tabId === (id === "local" ? null : id));
|
||||
if (inLayout) el.classList.add("active");
|
||||
|
||||
if (id !== "local" && status) {
|
||||
el.innerHTML = `<span class="${STATUS_COLOR[status] ?? "text-hint"}">${STATUS_DOT[status] ?? "○"}</span>`;
|
||||
}
|
||||
el.innerHTML += `<span>${escapeHtml(label)}</span>`;
|
||||
|
||||
// Drag — send tab-id
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("tab-id", id);
|
||||
el.classList.add("tab-dragging");
|
||||
setDraggedTabId(id === "local" ? null : id);
|
||||
});
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("tab-dragging");
|
||||
// Cleanup drop highlights
|
||||
document.querySelectorAll(".pane-cell.drop-active").forEach((c) => c.classList.remove("drop-active"));
|
||||
setDraggedTabId(undefined);
|
||||
});
|
||||
|
||||
// Click
|
||||
el.addEventListener("click", () => {
|
||||
openInPane(id === "local" ? null : id);
|
||||
if (id !== "local") {
|
||||
state.lastRemoteTabId = id;
|
||||
state.activePane = "remote";
|
||||
} else {
|
||||
state.activePane = "local";
|
||||
}
|
||||
});
|
||||
|
||||
// Close
|
||||
if (closable) {
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "tab-close";
|
||||
close.innerHTML = iconMarkup("close", 10);
|
||||
close.title = "Close";
|
||||
close.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(id);
|
||||
});
|
||||
el.appendChild(close);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
export function renderActivePane() {}
|
||||
118
frontend/js/components/toolbar.js
Normal file
118
frontend/js/components/toolbar.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { state, notify, activeTab } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { toggle as toggleHistory } from "./historyPopup.js";
|
||||
import { openMkdir, openDelete, openRename } from "./opDialogs.js";
|
||||
import { openSettings } from "./settingsDialog.js";
|
||||
import { queueUploadMany, queueDownloadMany, refreshLocal, refreshRemote } from "../fsActions.js";
|
||||
import { shortcutLabel, isMac } from "../platform.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
function pendingCount() {
|
||||
return state.queueTasks.filter((tk) => {
|
||||
const s = tk.state;
|
||||
const kind = typeof s === "string" ? s : Object.keys(s)[0];
|
||||
return kind === "running" || kind === "queued";
|
||||
}).length;
|
||||
}
|
||||
|
||||
function currentSelection() {
|
||||
if (state.activePane === "remote") {
|
||||
return activeTab()?.remoteSelected ?? [];
|
||||
}
|
||||
return state.local.selected;
|
||||
}
|
||||
|
||||
export function renderToolbar(container) {
|
||||
const tab = activeTab();
|
||||
const connected = tab?.status === "connected";
|
||||
|
||||
container.innerHTML = `
|
||||
<button type="button" class="btn btn-accent-dim" data-action="new-connection" title="${t("toolbar.newConnection")} (${shortcutLabel("N")})">
|
||||
${iconMarkup("addCircle", 15)}<span>${t("toolbar.newConnection")}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn btn-ghost" data-action="upload" ${connected && state.local.selected.length > 0 ? "" : "disabled"}>
|
||||
${iconMarkup("upload", 15)}<span>${t("common.upload")}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="download" ${connected && tab?.remoteSelected.length > 0 ? "" : "disabled"}>
|
||||
${iconMarkup("download", 15)}<span>${t("common.download")}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn btn-toggle ${state.showQueue ? "active" : ""}" data-action="toggle-queue">
|
||||
${iconMarkup("clock", 15)}<span>${t("toolbar.queueLabel")}${pendingCount() > 0 ? ` ${pendingCount()}` : ""}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="refresh" title="${t("common.refresh")} (${shortcutLabel("R")})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("refresh", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="new-folder" title="${t("common.newFolder")} (${shortcutLabel("N", { shift: true })})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("folderAdd", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete" title="${t("common.delete")} (${isMac ? "⌘⌫" : "Del"})" ${currentSelection().length > 0 ? "" : "disabled"}>${iconMarkup("trash", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="rename" title="${t("common.rename")} (${isMac ? "⏎" : "F2"})" ${currentSelection().length === 1 ? "" : "disabled"}>${iconMarkup("pen", 16)}</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="settings" title="${t("common.settings")} (${shortcutLabel(",")})">${iconMarkup("settings", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-toggle ${state.showHistoryPopup ? "active" : ""}" data-action="history" data-history-toggle title="${t("common.history")}">${iconMarkup("history", 16)}</button>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
|
||||
container.querySelector('[data-action="settings"]').addEventListener("click", openSettings);
|
||||
container.querySelector('[data-action="upload"]').addEventListener("click", () => {
|
||||
const t2 = activeTab();
|
||||
const entries = state.local.entries.filter((e) => state.local.selected.includes(e.name));
|
||||
if (t2 && entries.length > 0) queueUploadMany(t2, entries);
|
||||
});
|
||||
container.querySelector('[data-action="download"]').addEventListener("click", () => {
|
||||
const t2 = activeTab();
|
||||
const entries = t2?.remoteEntries.filter((e) => t2.remoteSelected.includes(e.name)) ?? [];
|
||||
if (t2 && entries.length > 0) queueDownloadMany(t2, entries);
|
||||
});
|
||||
container.querySelector('[data-action="toggle-queue"]').addEventListener("click", () => {
|
||||
state.showQueue = !state.showQueue;
|
||||
notify();
|
||||
});
|
||||
container.querySelector('[data-action="refresh"]').addEventListener("click", () => {
|
||||
if (state.activePane === "remote" && tab) refreshRemote(tab);
|
||||
else refreshLocal();
|
||||
});
|
||||
container.querySelector('[data-action="new-folder"]').addEventListener("click", () => {
|
||||
state.opTarget = state.activePane;
|
||||
openMkdir();
|
||||
});
|
||||
container.querySelector('[data-action="delete"]').addEventListener("click", () => {
|
||||
const names = currentSelection();
|
||||
if (names.length === 0) return;
|
||||
state.opTarget = state.activePane;
|
||||
openDelete(names);
|
||||
});
|
||||
container.querySelector('[data-action="rename"]').addEventListener("click", () => {
|
||||
const names = currentSelection();
|
||||
if (names.length !== 1) return;
|
||||
state.opTarget = state.activePane;
|
||||
openRename(names[0]);
|
||||
});
|
||||
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
|
||||
toggleHistory(e.currentTarget);
|
||||
});
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
const tab = activeTab();
|
||||
return JSON.stringify([
|
||||
state.activeTabId,
|
||||
state.activePane,
|
||||
tab?.status,
|
||||
tab?.remoteSelected,
|
||||
state.local.selected,
|
||||
state.showQueue,
|
||||
state.showHistoryPopup,
|
||||
pendingCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateToolbarIfNeeded(container) {
|
||||
const sig = computeSig();
|
||||
if (sig === lastSig) return;
|
||||
renderToolbar(container);
|
||||
}
|
||||
87
frontend/js/components/welcome.js
Normal file
87
frontend/js/components/welcome.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { renderHistoryRows } from "./historyList.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { openSettings } from "./settingsDialog.js";
|
||||
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { shortcutLabel } from "../platform.js";
|
||||
|
||||
export function renderWelcome(container) {
|
||||
container.innerHTML = `
|
||||
<div class="welcome">
|
||||
<div class="welcome-hero">
|
||||
<div class="welcome-logo">${iconMarkup("folder", 34)}</div>
|
||||
<div class="welcome-title">Wherry</div>
|
||||
<div class="welcome-subtitle">${t("welcome.subtitle")}</div>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button type="button" class="btn btn-accent welcome-cta" data-action="new-connection">
|
||||
${iconMarkup("addCircle", 15)}<span>${t("welcome.newConnection")}</span>
|
||||
<kbd class="kbd kbd-on-accent">${shortcutLabel("N")}</kbd>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="open-settings">
|
||||
${iconMarkup("settings", 15)}<span>${t("common.settings")}</span>
|
||||
<kbd class="kbd">${shortcutLabel(",")}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-status" data-role="status"></div>
|
||||
<div class="welcome-columns">
|
||||
<div class="welcome-card" data-role="sites" style="display:none">
|
||||
<div class="welcome-card-title">${iconMarkup("star", 12)}<span>${t("welcome.savedSites")}</span></div>
|
||||
<div data-role="sites-list"></div>
|
||||
</div>
|
||||
<div class="welcome-card" data-role="recent" style="display:none">
|
||||
<div class="welcome-card-title">${iconMarkup("history", 12)}<span>${t("welcome.recentConnections")}</span></div>
|
||||
<div data-role="recent-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
|
||||
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
|
||||
|
||||
updateWelcome(container);
|
||||
}
|
||||
|
||||
function renderSiteRows(container, sites) {
|
||||
container.innerHTML = "";
|
||||
for (const site of sites) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "recent-row";
|
||||
row.innerHTML = `
|
||||
<span class="icon icon--important">${iconMarkup("serverSquare", 13)}</span>
|
||||
<span class="recent-label">${escapeHtml(site.name)}</span>
|
||||
<span class="proto-badge">${PROTOCOL_LABELS[site.protocol] ?? site.protocol}</span>
|
||||
`;
|
||||
row.addEventListener("click", () => connectFromSite(site));
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWelcome(container) {
|
||||
const statusRole = container.querySelector('[data-role="status"]');
|
||||
if (!statusRole) return;
|
||||
|
||||
if (state.connectLoading) {
|
||||
statusRole.innerHTML = '<span class="spinner"></span>';
|
||||
} else if (state.connectError) {
|
||||
statusRole.innerHTML = `<span class="text-red" style="font-size:11.5px">${escapeHtml(state.connectError)}</span>`;
|
||||
} else {
|
||||
statusRole.innerHTML = "";
|
||||
}
|
||||
|
||||
const sitesWrap = container.querySelector('[data-role="sites"]');
|
||||
const sitesList = container.querySelector('[data-role="sites-list"]');
|
||||
const sites = state.sites.slice(0, 6);
|
||||
sitesWrap.style.display = sites.length ? "" : "none";
|
||||
if (sites.length) renderSiteRows(sitesList, sites);
|
||||
|
||||
const recentWrap = container.querySelector('[data-role="recent"]');
|
||||
const recentList = container.querySelector('[data-role="recent-list"]');
|
||||
const rows = state.history.slice(0, 6);
|
||||
recentWrap.style.display = rows.length ? "" : "none";
|
||||
if (rows.length) renderHistoryRows(recentList, rows);
|
||||
}
|
||||
157
frontend/js/connectActions.js
Normal file
157
frontend/js/connectActions.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Connection flow shared by the New Connection dialog, the welcome screen's
|
||||
// recent-connections list and the toolbar History popover. Mirrors
|
||||
// src/ui/dialogs/connection/{actions,history}.rs.
|
||||
import { state, notify } from "./store.js";
|
||||
import * as ipc from "./ipc.js";
|
||||
|
||||
export const DEFAULT_PORTS = { sftp: 22, ftp: 21, ftps: 990 };
|
||||
export const PROTOCOL_LABELS = { sftp: "SFTP", ftp: "FTP", ftps: "FTPS" };
|
||||
|
||||
function uuid() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
async function refreshHistory() {
|
||||
try {
|
||||
state.history = await ipc.listHistory();
|
||||
} catch {
|
||||
/* history is best-effort UI sugar, ignore failures */
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a connect attempt for already-built ConnectionParams; used by the
|
||||
* dialog's Connect button and by history reconnect/edit. */
|
||||
export async function spawnConnect(params) {
|
||||
state.connectError = "";
|
||||
state.connectLoading = true;
|
||||
notify();
|
||||
try {
|
||||
const connectionId = await ipc.connect(params);
|
||||
const entries = await ipc.remoteList(connectionId, "/");
|
||||
// The backend reuses the same connectionId for repeat connections to the
|
||||
// same host/port/username (see canonical_id in src/commands.rs::connect),
|
||||
// so reconnecting to an already-open server must update its existing tab
|
||||
// rather than push a second one with the same id.
|
||||
const existing = state.tabs.find((t) => t.id === connectionId);
|
||||
if (existing) {
|
||||
existing.label = params.label;
|
||||
existing.params = params;
|
||||
existing.status = "connected";
|
||||
existing.remotePath = "/";
|
||||
existing.remoteEntries = entries;
|
||||
existing.remoteSelected = [];
|
||||
existing.remoteSelectAnchor = null;
|
||||
existing.loading = false;
|
||||
} else {
|
||||
state.tabs.push({
|
||||
id: connectionId,
|
||||
label: params.label,
|
||||
params,
|
||||
status: "connected",
|
||||
remotePath: "/",
|
||||
remoteEntries: entries,
|
||||
remoteSelected: [],
|
||||
remoteSelectAnchor: null,
|
||||
loading: false,
|
||||
sort: { col: "name", dir: "asc" },
|
||||
});
|
||||
}
|
||||
state.activeTabId = connectionId;
|
||||
state.lastRemoteTabId = connectionId;
|
||||
state.activePane = "remote";
|
||||
// Replace first pane with new connection (click = switch, not split)
|
||||
state.layout.panes[0].tabId = connectionId;
|
||||
state.connectLoading = false;
|
||||
state.showConnectDialog = false;
|
||||
state.statusMessage = `Connected to ${params.host}`;
|
||||
notify();
|
||||
await refreshHistory();
|
||||
notify();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
state.connectLoading = false;
|
||||
state.connectError = String(err);
|
||||
state.statusMessage = `Connection failed: ${err}`;
|
||||
notify();
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates raw dialog form fields and turns them into ConnectionParams. */
|
||||
export function buildParamsFromForm(form) {
|
||||
const host = form.host.trim();
|
||||
const username = form.username.trim();
|
||||
const portStr = form.port.trim();
|
||||
|
||||
if (!host) return { ok: false, error: "connect.errorHost" };
|
||||
if (!username) return { ok: false, error: "connect.errorUser" };
|
||||
let port = DEFAULT_PORTS[form.protocol];
|
||||
if (portStr) {
|
||||
const parsed = Number(portStr);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
return { ok: false, error: "connect.errorPort" };
|
||||
}
|
||||
port = parsed;
|
||||
}
|
||||
|
||||
const protocolLabel = PROTOCOL_LABELS[form.protocol];
|
||||
const params = {
|
||||
id: uuid(),
|
||||
label: form.label.trim() || `${host} (${protocolLabel})`,
|
||||
protocol: form.protocol,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: form.password ? form.password : null,
|
||||
keyPath: form.keyPath ? form.keyPath : null,
|
||||
};
|
||||
return { ok: true, params };
|
||||
}
|
||||
|
||||
/** Click on a history row — reconnect straight away, no dialog. */
|
||||
export function reconnectFromHistory(entry) {
|
||||
const params = {
|
||||
id: entry.connId,
|
||||
label: `${entry.username}@${entry.host}`,
|
||||
protocol: entry.protocol,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
username: entry.username,
|
||||
password: null, // history doesn't store passwords
|
||||
keyPath: entry.keyPath ?? null,
|
||||
};
|
||||
return spawnConnect(params);
|
||||
}
|
||||
|
||||
/** Click on a saved-site row (welcome screen). */
|
||||
export function connectFromSite(site) {
|
||||
const params = {
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
protocol: site.protocol,
|
||||
host: site.host,
|
||||
port: site.port,
|
||||
username: site.username,
|
||||
password: site.password ?? null,
|
||||
keyPath: site.keyPath ?? null,
|
||||
};
|
||||
return spawnConnect(params);
|
||||
}
|
||||
|
||||
/** "Save" on a history row — turns a one-off connection into a permanent Site. */
|
||||
export async function saveHistoryAsSite(entry) {
|
||||
const site = {
|
||||
id: entry.connId,
|
||||
name: `${entry.username}@${entry.host}`,
|
||||
protocol: entry.protocol,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
username: entry.username,
|
||||
keyPath: entry.keyPath ?? null,
|
||||
folder: null,
|
||||
note: null,
|
||||
};
|
||||
await ipc.saveSite(site);
|
||||
state.statusMessage = `Saved ${site.name} as a site`;
|
||||
notify();
|
||||
}
|
||||
79
frontend/js/dnd.js
Normal file
79
frontend/js/dnd.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Native HTML5 drag & drop, standing in for egui's dnd_drag_source /
|
||||
// dnd_drop_zone. Payload shape mirrors the old Rust `DragPayload` enum:
|
||||
// { source: 'local', path, name }
|
||||
// { source: 'remote', path, name, connectionId }
|
||||
const MIME = "application/x-wherry-payload";
|
||||
|
||||
// "Spring-loaded folder" hover delay: you can't click a folder open while
|
||||
// your mouse is busy carrying a payload, so a drag has to linger this long
|
||||
// over a folder row (or another pane) before it auto-opens -- mirrors
|
||||
// Finder/Explorer's spring-loaded folders.
|
||||
export const HOVER_OPEN_DELAY = 900;
|
||||
|
||||
export function makeDraggable(el, entry, payload) {
|
||||
if (entry.name === "..") return; // parent row is never a drag source
|
||||
el.draggable = true;
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData(MIME, JSON.stringify(payload));
|
||||
requestAnimationFrame(() => el.classList.add("dragging"));
|
||||
});
|
||||
el.addEventListener("dragend", () => el.classList.remove("dragging"));
|
||||
}
|
||||
|
||||
/** opts.onHoverHold, if given, fires once after HOVER_OPEN_DELAY of
|
||||
* uninterrupted dragover -- used to spring a folder row open without
|
||||
* ending the drag. */
|
||||
export function makeDropTarget(el, { onDrop, onHoverChange, onHoverHold } = {}) {
|
||||
let holdTimer = null;
|
||||
const clearHold = () => {
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
};
|
||||
|
||||
el.addEventListener("dragover", (e) => {
|
||||
if (!e.dataTransfer.types.includes(MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
onHoverChange?.(true);
|
||||
if (onHoverHold && !holdTimer) {
|
||||
holdTimer = setTimeout(() => {
|
||||
holdTimer = null;
|
||||
onHoverHold();
|
||||
}, HOVER_OPEN_DELAY);
|
||||
}
|
||||
});
|
||||
el.addEventListener("dragleave", (e) => {
|
||||
if (el.contains(e.relatedTarget)) return; // moved onto a child, not actually left
|
||||
onHoverChange?.(false);
|
||||
clearHold();
|
||||
});
|
||||
el.addEventListener("drop", (e) => {
|
||||
if (!e.dataTransfer.types.includes(MIME)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // don't let the pane-level drop zone also fire for this same drop
|
||||
onHoverChange?.(false);
|
||||
clearHold();
|
||||
const raw = e.dataTransfer.getData(MIME);
|
||||
if (!raw) return;
|
||||
try {
|
||||
onDrop(JSON.parse(raw));
|
||||
} catch {
|
||||
/* malformed payload, ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tab-bar → pane drag tracking ────────────────────────────────────────
|
||||
// dataTransfer.getData() only returns data on dragstart/drop, not dragover
|
||||
// (a browser security restriction) -- so paneLayout.js can't read which tab
|
||||
// is mid-drag from the dragover event itself. Track it here instead; tabs.js
|
||||
// sets it on dragstart/dragend. `undefined` means no tab drag is in flight
|
||||
// (kept distinct from `null`, which is the Local tab's real id).
|
||||
let draggedTabId;
|
||||
export function setDraggedTabId(id) {
|
||||
draggedTabId = id;
|
||||
}
|
||||
export function getDraggedTabId() {
|
||||
return draggedTabId;
|
||||
}
|
||||
6
frontend/js/dom.js
Normal file
6
frontend/js/dom.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** Escapes text for safe interpolation into innerHTML template strings. */
|
||||
export function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
37
frontend/js/format.js
Normal file
37
frontend/js/format.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Formatting + TaskState helpers shared by the queue panel and status bar.
|
||||
// TaskState serializes from Rust as either a bare lowercase string
|
||||
// ("queued" | "running" | "paused" | "cancelled" | "completed") or, for the
|
||||
// two data-carrying variants, a single-key object: { failed: "message" } /
|
||||
// { retrying: 2 }.
|
||||
|
||||
export function formatSize(bytes) {
|
||||
if (bytes === null || bytes === undefined) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function formatTime(unixSecs) {
|
||||
if (unixSecs === null || unixSecs === undefined) return "";
|
||||
const d = new Date(unixSecs * 1000);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function taskStateKind(state) {
|
||||
if (typeof state === "string") return state;
|
||||
if (state && typeof state === "object") return Object.keys(state)[0];
|
||||
return "queued";
|
||||
}
|
||||
|
||||
export function taskStateDetail(state) {
|
||||
if (state && typeof state === "object") return Object.values(state)[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function progressPct(task) {
|
||||
if (!task.totalBytes) return 0;
|
||||
return Math.min(100, (task.transferredBytes / task.totalBytes) * 100);
|
||||
}
|
||||
384
frontend/js/fsActions.js
Normal file
384
frontend/js/fsActions.js
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
// Local/remote filesystem operations shared by the toolbar, sidebar, file
|
||||
// table context menus and drag&drop handlers. Mirrors the old
|
||||
// src/ui/panels/{local_pane,remote_pane}/actions.rs + app/ops.rs.
|
||||
import { state, notify, activeTab } from "./store.js";
|
||||
import * as ipc from "./ipc.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
function withParent(entries, path) {
|
||||
if (path === "/" || path === "") return entries;
|
||||
return [{ name: "..", path: parentPath(path), kind: "dir", size: null, modified: null, permissions: null }, ...entries];
|
||||
}
|
||||
|
||||
export function parentPath(path) {
|
||||
const trimmed = path.replace(/\/+$/, "");
|
||||
if (!trimmed || trimmed === "") return "/";
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
if (idx <= 0) return "/";
|
||||
return trimmed.slice(0, idx);
|
||||
}
|
||||
|
||||
// --- Local -------------------------------------------------------------------
|
||||
|
||||
export async function refreshLocal() {
|
||||
try {
|
||||
const entries = await ipc.localList(state.local.path);
|
||||
state.local.entries = withParent(entries, state.local.path);
|
||||
notify();
|
||||
} catch (err) {
|
||||
state.statusMessage = `Local list error: ${err}`;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateLocal(path) {
|
||||
state.local.path = path;
|
||||
state.local.selected = [];
|
||||
state.local.selectAnchor = null;
|
||||
// Navigating local (sidebar quick-access/bookmarks especially) has to
|
||||
// bring the Local pane into view -- otherwise clicking it while a remote
|
||||
// tab is displayed silently updates state nobody's looking at and reads
|
||||
// as "nothing happened". notify() here (before the async list call even
|
||||
// starts) is what makes the tab switch itself feel instant.
|
||||
state.activeTabId = null;
|
||||
state.activePane = "local";
|
||||
notify();
|
||||
refreshLocal();
|
||||
}
|
||||
|
||||
export async function initLocal() {
|
||||
const home = await ipc.localHomeDir();
|
||||
state.local.path = home;
|
||||
const qa = state.sidebar.quickAccess;
|
||||
const set = (name, suffix) => {
|
||||
const item = qa.find((q) => q.name === name);
|
||||
if (item) item.path = suffix ? `${home}/${suffix}` : home;
|
||||
};
|
||||
set("Home", "");
|
||||
set("Desktop", "Desktop");
|
||||
set("Documents", "Documents");
|
||||
set("Downloads", "Downloads");
|
||||
set("Pictures", "Pictures");
|
||||
set("Music", "Music");
|
||||
set("Videos", "Videos");
|
||||
await refreshLocal();
|
||||
try {
|
||||
state.sidebar.bookmarks = await ipc.listBookmarks();
|
||||
} catch {
|
||||
/* bookmarks table may be empty, fine */
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openLocalEntry(name) {
|
||||
if (name === "..") {
|
||||
navigateLocal(parentPath(state.local.path));
|
||||
return;
|
||||
}
|
||||
const entry = state.local.entries.find((e) => e.name === name);
|
||||
if (!entry) return;
|
||||
if (entry.kind === "dir") {
|
||||
navigateLocal(entry.path);
|
||||
} else {
|
||||
ipc
|
||||
.localOpen(entry.path)
|
||||
.then(() => {
|
||||
state.statusMessage = t("panels.opening", { name: entry.name });
|
||||
notify();
|
||||
})
|
||||
.catch((err) => {
|
||||
state.statusMessage = `Open failed: ${err}`;
|
||||
notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function addBookmark(name, path) {
|
||||
if (state.sidebar.bookmarks.some((b) => b.path === path)) {
|
||||
state.statusMessage = t("panels.alreadyBookmarked");
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const id = await ipc.addBookmark(name, path);
|
||||
state.sidebar.bookmarks.push({ id, name, path });
|
||||
state.statusMessage = t("panels.bookmarkAdded");
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function removeBookmark(id) {
|
||||
await ipc.removeBookmark(id);
|
||||
state.sidebar.bookmarks = state.sidebar.bookmarks.filter((b) => b.id !== id);
|
||||
notify();
|
||||
}
|
||||
|
||||
// --- Remote --------------------------------------------------------------------
|
||||
|
||||
export async function refreshRemote(tab) {
|
||||
tab.loading = true;
|
||||
notify();
|
||||
try {
|
||||
const entries = await ipc.remoteList(tab.id, tab.remotePath);
|
||||
tab.remoteEntries = withParent(entries, tab.remotePath);
|
||||
} catch (err) {
|
||||
state.statusMessage = `Remote list error: ${err}`;
|
||||
} finally {
|
||||
tab.loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateRemote(tab, path) {
|
||||
tab.remotePath = path;
|
||||
tab.remoteSelected = [];
|
||||
tab.remoteSelectAnchor = null;
|
||||
if (!tab.loading) refreshRemote(tab);
|
||||
}
|
||||
|
||||
export function openRemoteEntry(tab, name) {
|
||||
if (name === "..") {
|
||||
navigateRemote(tab, parentPath(tab.remotePath));
|
||||
return;
|
||||
}
|
||||
const entry = tab.remoteEntries.find((e) => e.name === name);
|
||||
if (!entry) return;
|
||||
if (entry.kind === "dir") {
|
||||
navigateRemote(tab, entry.path);
|
||||
} else {
|
||||
queueDownload(tab, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTab(tabId) {
|
||||
ipc.disconnect(tabId);
|
||||
state.tabs = state.tabs.filter((t) => t.id !== tabId);
|
||||
// Clean up layout — remove panes referencing this tab, or reset to Local
|
||||
state.layout.panes = state.layout.panes
|
||||
.map((p) => (p.tabId === tabId ? { ...p, tabId: null } : p))
|
||||
// If we ended up with duplicate Local panes, merge them
|
||||
.filter((p, i, arr) => p.tabId !== null || arr.findIndex((x) => x.tabId === null) === i);
|
||||
// Keep at least one pane
|
||||
if (state.layout.panes.length === 0) {
|
||||
state.layout.panes = [{ id: "pane-0", tabId: null }];
|
||||
}
|
||||
if (state.activeTabId === tabId) {
|
||||
state.activeTabId = null;
|
||||
state.activePane = "local";
|
||||
}
|
||||
if (state.lastRemoteTabId === tabId) {
|
||||
state.lastRemoteTabId = state.tabs.at(-1)?.id ?? null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// --- Transfers -------------------------------------------------------------------
|
||||
|
||||
// Optimistic insert so the queue panel/toolbar counter update the instant the
|
||||
// user clicks Upload/Download, instead of waiting for the worker to pick the
|
||||
// task up and emit its first transfer-state-changed event.
|
||||
function addOptimisticTask(id, kind, connectionId, localPath, remotePath, fileName, totalBytes) {
|
||||
state.queueTasks.push({
|
||||
id,
|
||||
kind,
|
||||
connectionId,
|
||||
localPath,
|
||||
remotePath,
|
||||
fileName,
|
||||
totalBytes,
|
||||
transferredBytes: 0,
|
||||
state: "queued",
|
||||
speed: null,
|
||||
etaSecs: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function queueUpload(tab, localEntry) {
|
||||
const remotePath = `${tab.remotePath.replace(/\/+$/, "")}/${localEntry.name}`;
|
||||
const totalBytes = localEntry.size ?? 0;
|
||||
const id = await ipc.enqueueTransfer("upload", tab.id, localEntry.path, remotePath, localEntry.name, totalBytes);
|
||||
addOptimisticTask(id, "upload", tab.id, localEntry.path, remotePath, localEntry.name, totalBytes);
|
||||
state.statusMessage = t("toolbar.uploadQueued", { name: localEntry.name });
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function queueDownload(tab, remoteEntry) {
|
||||
const localPath = `${state.local.path.replace(/\/+$/, "")}/${remoteEntry.name}`;
|
||||
const totalBytes = remoteEntry.size ?? 0;
|
||||
const id = await ipc.enqueueTransfer("download", tab.id, localPath, remoteEntry.path, remoteEntry.name, totalBytes);
|
||||
addOptimisticTask(id, "download", tab.id, localPath, remoteEntry.path, remoteEntry.name, totalBytes);
|
||||
state.statusMessage = t("toolbar.downloadQueued", { name: remoteEntry.name });
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function queueUploadMany(tab, localEntries) {
|
||||
if (localEntries.length === 1) return queueUpload(tab, localEntries[0]);
|
||||
for (const entry of localEntries) {
|
||||
const remotePath = `${tab.remotePath.replace(/\/+$/, "")}/${entry.name}`;
|
||||
const totalBytes = entry.size ?? 0;
|
||||
const id = await ipc.enqueueTransfer("upload", tab.id, entry.path, remotePath, entry.name, totalBytes);
|
||||
addOptimisticTask(id, "upload", tab.id, entry.path, remotePath, entry.name, totalBytes);
|
||||
}
|
||||
state.statusMessage = t("toolbar.uploadQueuedMulti", { n: localEntries.length });
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function queueDownloadMany(tab, remoteEntries) {
|
||||
if (remoteEntries.length === 1) return queueDownload(tab, remoteEntries[0]);
|
||||
for (const entry of remoteEntries) {
|
||||
const localPath = `${state.local.path.replace(/\/+$/, "")}/${entry.name}`;
|
||||
const totalBytes = entry.size ?? 0;
|
||||
const id = await ipc.enqueueTransfer("download", tab.id, localPath, entry.path, entry.name, totalBytes);
|
||||
addOptimisticTask(id, "download", tab.id, localPath, entry.path, entry.name, totalBytes);
|
||||
}
|
||||
state.statusMessage = t("toolbar.downloadQueuedMulti", { n: remoteEntries.length });
|
||||
notify();
|
||||
}
|
||||
|
||||
// --- Drag & drop -----------------------------------------------------------------
|
||||
|
||||
// A folder can't be dropped into itself or into one of its own descendants —
|
||||
// filters those out before a move is attempted so it fails silently instead
|
||||
// of round-tripping an OS-level rename error to the status bar.
|
||||
function isSelfOrDescendant(itemPath, destDir) {
|
||||
const src = itemPath.replace(/\/+$/, "");
|
||||
return destDir === src || destDir.startsWith(`${src}/`);
|
||||
}
|
||||
|
||||
export async function handleDropOnLocal(payload, destDir) {
|
||||
const items = payload.items ?? [];
|
||||
if (payload.source === "remote") {
|
||||
const tab = state.tabs.find((tb) => tb.id === payload.connectionId);
|
||||
if (!tab) {
|
||||
state.statusMessage = t("panels.noActiveConnection");
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const localPath = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
||||
const id = await ipc.enqueueTransfer("download", tab.id, localPath, item.path, item.name, 0);
|
||||
addOptimisticTask(id, "download", tab.id, localPath, item.path, item.name, 0);
|
||||
}
|
||||
state.statusMessage =
|
||||
items.length === 1 ? t("toolbar.downloadQueued", { name: items[0].name }) : t("toolbar.downloadQueuedMulti", { n: items.length });
|
||||
notify();
|
||||
} else {
|
||||
const toMove = items.filter((item) => parentPath(item.path) !== destDir && !isSelfOrDescendant(item.path, destDir));
|
||||
if (toMove.length === 0) return;
|
||||
let failed = 0;
|
||||
for (const item of toMove) {
|
||||
try {
|
||||
await ipc.localMoveInto(item.path, destDir);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
await refreshLocal();
|
||||
state.statusMessage = failed > 0 ? t("panels.moveFailedCount", { n: failed }) : t("panels.moved", { n: toMove.length });
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDropOnRemote(tab, payload, destDir) {
|
||||
const items = payload.items ?? [];
|
||||
if (payload.source === "local") {
|
||||
for (const item of items) {
|
||||
const dest = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
||||
const id = await ipc.enqueueTransfer("upload", tab.id, item.path, dest, item.name, 0);
|
||||
addOptimisticTask(id, "upload", tab.id, item.path, dest, item.name, 0);
|
||||
}
|
||||
state.statusMessage =
|
||||
items.length === 1 ? t("toolbar.uploadQueued", { name: items[0].name }) : t("toolbar.uploadQueuedMulti", { n: items.length });
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (payload.connectionId !== tab.id) {
|
||||
state.statusMessage = t("panels.cannotMoveCrossConnection");
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const toMove = items.filter((item) => parentPath(item.path) !== destDir && !isSelfOrDescendant(item.path, destDir));
|
||||
if (toMove.length === 0) return;
|
||||
state.statusMessage =
|
||||
toMove.length === 1 ? t("panels.movingFile", { name: toMove[0].name }) : t("panels.movingFiles", { n: toMove.length });
|
||||
notify();
|
||||
let failed = 0;
|
||||
for (const item of toMove) {
|
||||
const dest = `${destDir.replace(/\/+$/, "")}/${item.name}`;
|
||||
try {
|
||||
await ipc.remoteRename(tab.id, item.path, dest);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
await refreshRemote(tab);
|
||||
state.statusMessage = failed > 0 ? t("panels.moveFailedCount", { n: failed }) : t("panels.moved", { n: toMove.length });
|
||||
notify();
|
||||
}
|
||||
|
||||
// --- New Folder / Rename / Delete -------------------------------------------------
|
||||
|
||||
export async function doMkdir(name) {
|
||||
if (state.opTarget === "local") {
|
||||
const path = `${state.local.path.replace(/\/+$/, "")}/${name}`;
|
||||
await ipc.localMkdir(path);
|
||||
state.statusMessage = "Folder created";
|
||||
await refreshLocal();
|
||||
} else {
|
||||
const tab = activeTab();
|
||||
if (!tab) return;
|
||||
const path = `${tab.remotePath.replace(/\/+$/, "")}/${name}`;
|
||||
await ipc.remoteMkdir(tab.id, path);
|
||||
state.statusMessage = "Folder created";
|
||||
await refreshRemote(tab);
|
||||
}
|
||||
}
|
||||
|
||||
export async function doRename(oldName, newName) {
|
||||
if (state.opTarget === "local") {
|
||||
const entry = state.local.entries.find((e) => e.name === oldName);
|
||||
if (!entry) return;
|
||||
const to = `${parentPath(entry.path)}/${newName}`;
|
||||
await ipc.localRename(entry.path, to);
|
||||
state.statusMessage = "Renamed";
|
||||
await refreshLocal();
|
||||
} else {
|
||||
const tab = activeTab();
|
||||
if (!tab) return;
|
||||
const entry = tab.remoteEntries.find((e) => e.name === oldName);
|
||||
if (!entry) return;
|
||||
const to = `${parentPath(entry.path)}/${newName}`;
|
||||
await ipc.remoteRename(tab.id, entry.path, to);
|
||||
state.statusMessage = "Renamed";
|
||||
await refreshRemote(tab);
|
||||
}
|
||||
}
|
||||
|
||||
export async function doDelete(names) {
|
||||
let failed = 0;
|
||||
if (state.opTarget === "local") {
|
||||
for (const name of names) {
|
||||
const entry = state.local.entries.find((e) => e.name === name);
|
||||
if (!entry) continue;
|
||||
try {
|
||||
await ipc.localDelete(entry.path);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
state.statusMessage = failed > 0 ? t("panels.deleteFailedCount", { n: failed }) : t("panels.deleted", { n: names.length });
|
||||
await refreshLocal();
|
||||
} else {
|
||||
const tab = activeTab();
|
||||
if (!tab) return;
|
||||
for (const name of names) {
|
||||
const entry = tab.remoteEntries.find((e) => e.name === name);
|
||||
if (!entry) continue;
|
||||
try {
|
||||
await ipc.remoteDelete(tab.id, entry.path);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
state.statusMessage = failed > 0 ? t("panels.deleteFailedCount", { n: failed }) : t("panels.deleted", { n: names.length });
|
||||
await refreshRemote(tab);
|
||||
}
|
||||
}
|
||||
58
frontend/js/i18n.js
Normal file
58
frontend/js/i18n.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Minimal i18n layer. UI code is written entirely in English strings pulled
|
||||
// through t(), never inline literals, so adding a language later is just:
|
||||
// 1. add js/i18n/xx.js (only needs to cover the keys it wants to change)
|
||||
// 2. register it in `dictionaries` below
|
||||
// 3. call setLocale('xx') — every already-rendered t() call site updates
|
||||
// on next re-render, nothing else in the app needs to change.
|
||||
import en from "./i18n/en.js";
|
||||
import ru from "./i18n/ru.js";
|
||||
import es from "./i18n/es.js";
|
||||
import fr from "./i18n/fr.js";
|
||||
import de from "./i18n/de.js";
|
||||
import it from "./i18n/it.js";
|
||||
import pt from "./i18n/pt.js";
|
||||
import pl from "./i18n/pl.js";
|
||||
import zh from "./i18n/zh.js";
|
||||
import ja from "./i18n/ja.js";
|
||||
import ko from "./i18n/ko.js";
|
||||
import tr from "./i18n/tr.js";
|
||||
|
||||
const dictionaries = { en, ru, es, fr, de, it, pt, pl, zh, ja, ko, tr };
|
||||
|
||||
let currentLocale = "en";
|
||||
const listeners = new Set();
|
||||
|
||||
function lookup(dict, path) {
|
||||
return path.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), dict);
|
||||
}
|
||||
|
||||
export function availableLocales() {
|
||||
return Object.keys(dictionaries);
|
||||
}
|
||||
|
||||
export function getLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
if (!dictionaries[locale] || locale === currentLocale) return;
|
||||
currentLocale = locale;
|
||||
listeners.forEach((fn) => fn(locale));
|
||||
}
|
||||
|
||||
export function onLocaleChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function t(key, vars) {
|
||||
let str = lookup(dictionaries[currentLocale], key);
|
||||
if (str === undefined) str = lookup(dictionaries.en, key);
|
||||
if (str === undefined) return key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
str = str.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
175
frontend/js/i18n/de.js
Normal file
175
frontend/js/i18n/de.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Deutsch — Übersetzungen. Fehlende Schlüssel werden automatisch aus en.js übernommen.
|
||||
export default {
|
||||
common: {
|
||||
open: "Öffnen",
|
||||
rename: "Umbenennen",
|
||||
delete: "Löschen",
|
||||
refresh: "Aktualisieren",
|
||||
settings: "Einstellungen",
|
||||
history: "Verlauf",
|
||||
edit: "Bearbeiten",
|
||||
save: "Speichern",
|
||||
cancel: "Abbrechen",
|
||||
ok: "OK",
|
||||
copyPath: "Pfad kopieren",
|
||||
newFolder: "Neuer Ordner",
|
||||
upload: "Hochladen",
|
||||
download: "Herunterladen",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Neue Verbindung",
|
||||
queueLabel: "Warteschlange",
|
||||
uploadQueued: "Upload eingereiht: {name}",
|
||||
downloadQueued: "Download eingereiht: {name}",
|
||||
uploadQueuedMulti: "{n} Uploads eingereiht",
|
||||
downloadQueuedMulti: "{n} Downloads eingereiht",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP-Client",
|
||||
newConnection: "Neue Verbindung",
|
||||
recentConnections: "Letzte Verbindungen",
|
||||
savedSites: "Gespeicherte Seiten",
|
||||
},
|
||||
connect: {
|
||||
title: "Neue Verbindung",
|
||||
host: "Host",
|
||||
username: "Benutzername",
|
||||
port: "Port",
|
||||
password: "Passwort",
|
||||
keyHint: "Leer lassen, um sich mit einem SSH-Schlüssel anzumelden (Agent oder ~/.ssh).",
|
||||
keyFile: "Schlüsseldatei (optional)",
|
||||
browse: "Durchsuchen…",
|
||||
label: "Bezeichnung (optional)",
|
||||
connect: "Verbinden",
|
||||
connecting: "Verbinde…",
|
||||
errorHost: "Host ist erforderlich.",
|
||||
errorUser: "Benutzername ist erforderlich.",
|
||||
errorPort: "Der Port muss eine Zahl zwischen 1 und 65535 sein.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Letzte Verbindungen",
|
||||
noHistory: "Noch kein Verbindungsverlauf",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOKAL",
|
||||
remoteLabel: "REMOTE — {host}",
|
||||
loading: "Wird geladen…",
|
||||
countItems: "{n} Elemente",
|
||||
colName: "Name",
|
||||
colModified: "Geändert",
|
||||
colType: "Typ",
|
||||
colSize: "Größe",
|
||||
typeDir: "Ordner",
|
||||
typeFile: "Datei",
|
||||
typeLink: "Link",
|
||||
pathCopied: "Pfad kopiert",
|
||||
sidebarDrives: "LAUFWERKE",
|
||||
sidebarBookmarks: "LESEZEICHEN",
|
||||
sidebarRoot: "Stammverzeichnis",
|
||||
sidebarRemoveBookmark: "Lesezeichen entfernen",
|
||||
bookmarkThisFolder: "Diesen Ordner als Lesezeichen speichern",
|
||||
addToBookmarks: "Zu Lesezeichen hinzufügen",
|
||||
alreadyBookmarked: "Bereits als Lesezeichen gespeichert",
|
||||
bookmarkAdded: "Lesezeichen hinzugefügt",
|
||||
uploadToServer: "Auf Server hochladen",
|
||||
downloadToLocal: "Lokal herunterladen",
|
||||
notConnected: "Nicht verbunden",
|
||||
movingFile: "Verschiebe {name}…",
|
||||
movingFiles: "Verschiebe {n} Elemente…",
|
||||
moved: "{n} Elemente verschoben",
|
||||
moveFailedCount: "{n} Elemente konnten nicht verschoben werden",
|
||||
deleted: "{n} Elemente gelöscht",
|
||||
deleteFailedCount: "{n} Elemente konnten nicht gelöscht werden",
|
||||
cannotMoveCrossConnection: "Verschieben zwischen unterschiedlichen Verbindungen nicht möglich",
|
||||
noActiveConnection: "Keine aktive Verbindung",
|
||||
opening: "{name} wird geöffnet…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} Übertragungen",
|
||||
doneSuffix: "{done}/{total} fertig",
|
||||
failedSuffix: "{n} fehlgeschlagen",
|
||||
empty: "Noch keine Übertragungen",
|
||||
stateQueued: "Wartend",
|
||||
stateUploading: "Wird hochgeladen…",
|
||||
stateDownloading: "Wird heruntergeladen…",
|
||||
stateComplete: "Fertig",
|
||||
stateFailed: "Fehlgeschlagen",
|
||||
statePaused: "Pausiert",
|
||||
stateCancelled: "Abgebrochen",
|
||||
stateRetrying: "Erneuter Versuch…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Nicht verbunden",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Neuer Ordner",
|
||||
newFolderPlaceholder: "Ordnername",
|
||||
renameTitle: "Umbenennen",
|
||||
renamePlaceholder: "Neuer Name",
|
||||
deleteTitle: "„{name}\" löschen?",
|
||||
deleteTitleMulti: "{n} Elemente löschen?",
|
||||
deleteBody: "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
navGeneral: "Allgemein",
|
||||
navAppearance: "Erscheinungsbild",
|
||||
navConnections: "Verbindungen",
|
||||
navHistory: "Verlauf",
|
||||
navSecurity: "Sicherheit",
|
||||
navTransfers: "Übertragungen",
|
||||
navAbout: "Über",
|
||||
|
||||
language: "Sprache",
|
||||
languageHint: "Sprache der Benutzeroberfläche.",
|
||||
confirmDelete: "Vor dem Löschen bestätigen",
|
||||
confirmDeleteHint: "Vor dem Löschen lokaler oder entfernter Dateien nachfragen.",
|
||||
startupFolder: "Startordner",
|
||||
startupFolderHint: "Der lokale Bereich wird hier geöffnet, wenn Wherry startet.",
|
||||
homeDirectory: "Home-Verzeichnis",
|
||||
useCurrentFolder: "Aktuellen Ordner verwenden",
|
||||
resetToHome: "Auf Home zurücksetzen",
|
||||
|
||||
appearanceHint: "Wählen Sie, welche Bereiche im Hauptfenster sichtbar sind.",
|
||||
toolbar: "Symbolleiste",
|
||||
sidebar: "Seitenleiste",
|
||||
statusBar: "Statusleiste",
|
||||
transferQueue: "Übertragungswarteschlange",
|
||||
|
||||
sites: "Gespeicherte Seiten",
|
||||
noSites: "Noch keine gespeicherten Seiten",
|
||||
deleteSiteHover: "Diese Seite löschen",
|
||||
removedSite: "Seite „{name}\" entfernt",
|
||||
bookmarksTitle: "Lesezeichen",
|
||||
noBookmarks: "Noch keine Lesezeichen",
|
||||
bookmarkRemoved: "Lesezeichen entfernt",
|
||||
|
||||
historyTitle: "Verbindungsverlauf",
|
||||
historyEmpty: "Noch kein Verbindungsverlauf",
|
||||
confirmClear: "Gesamten Verbindungsverlauf löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
clearAll: "Gesamten Verlauf löschen",
|
||||
deleteAll: "Alles löschen",
|
||||
cleared: "Verlauf gelöscht",
|
||||
|
||||
securityHint: "Passwörter werden in der lokalen Datenbank von Wherry gespeichert.",
|
||||
noConnections: "Keine gespeicherten Verbindungen",
|
||||
forgetPasswordHover: "Gespeichertes Passwort vergessen",
|
||||
passwordForgotten: "Passwort vergessen",
|
||||
forgetAll: "Alle gespeicherten Passwörter vergessen",
|
||||
allForgotten: "Alle gespeicherten Passwörter vergessen",
|
||||
|
||||
concurrentTitle: "Gleichzeitige Übertragungen",
|
||||
concurrentHint: "Wie viele Uploads/Downloads gleichzeitig laufen können.",
|
||||
autoRemoveTitle: "Abgeschlossene Übertragungen automatisch entfernen",
|
||||
autoRemoveHint: "Entfernt abgeschlossene Übertragungen automatisch aus der Warteschlange.",
|
||||
never: "Nie",
|
||||
tenSec: "10 s",
|
||||
thirtySec: "30 s",
|
||||
oneMin: "1 Min",
|
||||
|
||||
version: "Version",
|
||||
license: "Lizenz",
|
||||
database: "Datenbank",
|
||||
revealInFolder: "Im Ordner zeigen",
|
||||
},
|
||||
};
|
||||
186
frontend/js/i18n/en.js
Normal file
186
frontend/js/i18n/en.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// English strings — the fallback locale. Every other locale (see i18n.js
|
||||
// `dictionaries` map) only needs to cover the keys it wants to override;
|
||||
// missing keys fall back to this file automatically.
|
||||
export default {
|
||||
common: {
|
||||
open: "Open",
|
||||
rename: "Rename",
|
||||
delete: "Delete",
|
||||
refresh: "Refresh",
|
||||
settings: "Settings",
|
||||
history: "History",
|
||||
edit: "Edit",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
ok: "OK",
|
||||
copyPath: "Copy path",
|
||||
newFolder: "New folder",
|
||||
upload: "Upload",
|
||||
download: "Download",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "New Connection",
|
||||
queueLabel: "Queue",
|
||||
uploadQueued: "Queued upload: {name}",
|
||||
downloadQueued: "Queued download: {name}",
|
||||
uploadQueuedMulti: "Queued {n} uploads",
|
||||
downloadQueuedMulti: "Queued {n} downloads",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP client",
|
||||
newConnection: "New Connection",
|
||||
recentConnections: "Recent connections",
|
||||
savedSites: "Saved sites",
|
||||
},
|
||||
connect: {
|
||||
title: "New Connection",
|
||||
host: "Host",
|
||||
username: "Username",
|
||||
port: "Port",
|
||||
password: "Password",
|
||||
keyHint: "Leave empty to sign in with an SSH key (agent or ~/.ssh).",
|
||||
keyFile: "Key file (optional)",
|
||||
browse: "Browse…",
|
||||
label: "Label (optional)",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting…",
|
||||
errorHost: "Host is required.",
|
||||
errorUser: "Username is required.",
|
||||
errorPort: "Port must be a number between 1 and 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Recent connections",
|
||||
noHistory: "No connection history yet",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOCAL",
|
||||
remoteLabel: "REMOTE — {host}",
|
||||
loading: "Loading…",
|
||||
countItems: "{n} items",
|
||||
colName: "Name",
|
||||
colModified: "Modified",
|
||||
colType: "Type",
|
||||
colSize: "Size",
|
||||
typeDir: "Folder",
|
||||
typeFile: "File",
|
||||
typeLink: "Link",
|
||||
pathCopied: "Path copied",
|
||||
sidebarDrives: "Drives",
|
||||
sidebarBookmarks: "Bookmarks",
|
||||
sidebarRoot: "Root",
|
||||
sidebarRemoveBookmark: "Remove bookmark",
|
||||
bookmarkThisFolder: "Bookmark this folder",
|
||||
addToBookmarks: "Add to bookmarks",
|
||||
alreadyBookmarked: "Already bookmarked",
|
||||
bookmarkAdded: "Bookmark added",
|
||||
uploadToServer: "Upload to server",
|
||||
downloadToLocal: "Download to local",
|
||||
notConnected: "Not connected",
|
||||
movingFile: "Moving {name}…",
|
||||
movingFiles: "Moving {n} items…",
|
||||
moved: "Moved {n} items",
|
||||
moveFailedCount: "{n} items failed to move",
|
||||
deleted: "Deleted {n} items",
|
||||
deleteFailedCount: "{n} items failed to delete",
|
||||
cannotMoveCrossConnection: "Can't move files between different connections",
|
||||
noActiveConnection: "No active connection",
|
||||
opening: "Opening {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} transfers",
|
||||
doneSuffix: "{done}/{total} done",
|
||||
failedSuffix: "{n} failed",
|
||||
empty: "No transfers yet",
|
||||
stateQueued: "Queued",
|
||||
stateUploading: "Uploading",
|
||||
stateDownloading: "Downloading",
|
||||
stateComplete: "Complete",
|
||||
stateFailed: "Failed",
|
||||
statePaused: "Paused",
|
||||
stateCancelled: "Cancelled",
|
||||
stateRetrying: "Retrying",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Not connected",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "New Folder",
|
||||
newFolderPlaceholder: "Folder name",
|
||||
renameTitle: "Rename",
|
||||
renamePlaceholder: "New name",
|
||||
deleteTitle: "Delete {name}?",
|
||||
deleteTitleMulti: "Delete {n} items?",
|
||||
deleteBody: "This can't be undone.",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
navGeneral: "General",
|
||||
navAppearance: "Appearance",
|
||||
navConnections: "Connections",
|
||||
navHistory: "History",
|
||||
navSecurity: "Security",
|
||||
navTransfers: "Transfers",
|
||||
navAbout: "About",
|
||||
|
||||
confirmDelete: "Confirm before delete",
|
||||
confirmDeleteHint: "Ask before deleting files or folders, locally or on the server.",
|
||||
language: "Language",
|
||||
languageHint: "Interface language.",
|
||||
startupFolder: "Startup folder",
|
||||
startupFolderHint: "Local pane opens here when Wherry starts.",
|
||||
homeDirectory: "Home directory",
|
||||
useCurrentFolder: "Use current folder",
|
||||
resetToHome: "Reset to home",
|
||||
|
||||
appearanceHint: "Toggle which parts of the window are visible.",
|
||||
theme: "Theme",
|
||||
themeHint: "Choose the application colour scheme.",
|
||||
themeEmber: "Ember — Dark · Copper",
|
||||
themeFrost: "Frost — Light · Blue",
|
||||
themeMidnight: "Midnight — Dark · Indigo",
|
||||
themeForest: "Forest — Dark · Green",
|
||||
themeDawn: "Dawn — Light · Peach",
|
||||
themeAsh: "Ash — High-contrast Dark · Neutral",
|
||||
toolbar: "Toolbar",
|
||||
sidebar: "Sidebar",
|
||||
statusBar: "Status bar",
|
||||
transferQueue: "Transfer queue",
|
||||
|
||||
sites: "Saved sites",
|
||||
noSites: "No saved sites yet",
|
||||
deleteSiteHover: "Remove this site",
|
||||
removedSite: "Removed {name}",
|
||||
bookmarksTitle: "Bookmarks",
|
||||
noBookmarks: "No bookmarks yet",
|
||||
bookmarkRemoved: "Bookmark removed",
|
||||
|
||||
historyTitle: "Connection history",
|
||||
historyEmpty: "No connection history yet",
|
||||
confirmClear: "Clear all connection history? This can't be undone.",
|
||||
clearAll: "Clear all history",
|
||||
deleteAll: "Delete all",
|
||||
cleared: "History cleared",
|
||||
|
||||
securityHint:
|
||||
"Passwords are stored in Wherry's local database.",
|
||||
noConnections: "No saved connections",
|
||||
forgetPasswordHover: "Forget saved password",
|
||||
passwordForgotten: "Password forgotten",
|
||||
forgetAll: "Forget all saved passwords",
|
||||
allForgotten: "All saved passwords forgotten",
|
||||
|
||||
concurrentTitle: "Concurrent transfers",
|
||||
concurrentHint: "How many uploads/downloads can run at the same time.",
|
||||
autoRemoveTitle: "Auto-clear completed transfers",
|
||||
autoRemoveHint: "Automatically remove finished transfers from the queue.",
|
||||
never: "Never",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Version",
|
||||
license: "License",
|
||||
database: "Database",
|
||||
revealInFolder: "Reveal in folder",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/es.js
Normal file
175
frontend/js/i18n/es.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Español — traducciones. Las claves faltantes se toman automáticamente de en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Abrir",
|
||||
rename: "Renombrar",
|
||||
delete: "Eliminar",
|
||||
refresh: "Actualizar",
|
||||
settings: "Configuración",
|
||||
history: "Historial",
|
||||
edit: "Editar",
|
||||
save: "Guardar",
|
||||
cancel: "Cancelar",
|
||||
ok: "Aceptar",
|
||||
copyPath: "Copiar ruta",
|
||||
newFolder: "Nueva carpeta",
|
||||
upload: "Subir",
|
||||
download: "Descargar",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Nueva conexión",
|
||||
queueLabel: "Cola",
|
||||
uploadQueued: "Subida en cola: {name}",
|
||||
downloadQueued: "Descarga en cola: {name}",
|
||||
uploadQueuedMulti: "{n} subidas en cola",
|
||||
downloadQueuedMulti: "{n} descargas en cola",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "Cliente FTP / SFTP",
|
||||
newConnection: "Nueva conexión",
|
||||
recentConnections: "Conexiones recientes",
|
||||
savedSites: "Sitios guardados",
|
||||
},
|
||||
connect: {
|
||||
title: "Nueva conexión",
|
||||
host: "Host",
|
||||
username: "Usuario",
|
||||
port: "Puerto",
|
||||
password: "Contraseña",
|
||||
keyHint: "Déjalo vacío para iniciar sesión con una clave SSH (agente o ~/.ssh).",
|
||||
keyFile: "Archivo de clave (opcional)",
|
||||
browse: "Examinar…",
|
||||
label: "Etiqueta (opcional)",
|
||||
connect: "Conectar",
|
||||
connecting: "Conectando…",
|
||||
errorHost: "El host es obligatorio.",
|
||||
errorUser: "El usuario es obligatorio.",
|
||||
errorPort: "El puerto debe ser un número entre 1 y 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Conexiones recientes",
|
||||
noHistory: "Aún no hay historial de conexiones",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOCAL",
|
||||
remoteLabel: "REMOTO — {host}",
|
||||
loading: "Cargando…",
|
||||
countItems: "{n} elementos",
|
||||
colName: "Nombre",
|
||||
colModified: "Modificado",
|
||||
colType: "Tipo",
|
||||
colSize: "Tamaño",
|
||||
typeDir: "Carpeta",
|
||||
typeFile: "Archivo",
|
||||
typeLink: "Enlace",
|
||||
pathCopied: "Ruta copiada",
|
||||
sidebarDrives: "UNIDADES",
|
||||
sidebarBookmarks: "MARCADORES",
|
||||
sidebarRoot: "Raíz",
|
||||
sidebarRemoveBookmark: "Quitar marcador",
|
||||
bookmarkThisFolder: "Marcar esta carpeta",
|
||||
addToBookmarks: "Añadir a marcadores",
|
||||
alreadyBookmarked: "Ya está en marcadores",
|
||||
bookmarkAdded: "Marcador añadido",
|
||||
uploadToServer: "Subir al servidor",
|
||||
downloadToLocal: "Descargar a local",
|
||||
notConnected: "Sin conexión",
|
||||
movingFile: "Moviendo {name}…",
|
||||
movingFiles: "Moviendo {n} elementos…",
|
||||
moved: "Movidos {n} elementos",
|
||||
moveFailedCount: "{n} elementos no pudieron moverse",
|
||||
deleted: "Eliminados {n} elementos",
|
||||
deleteFailedCount: "{n} elementos no pudieron eliminarse",
|
||||
cannotMoveCrossConnection: "No se puede mover entre conexiones diferentes",
|
||||
noActiveConnection: "Sin conexión activa",
|
||||
opening: "Abriendo {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} transferencias",
|
||||
doneSuffix: "{done}/{total} completado",
|
||||
failedSuffix: "{n} con errores",
|
||||
empty: "Aún no hay transferencias",
|
||||
stateQueued: "En cola",
|
||||
stateUploading: "Subiendo…",
|
||||
stateDownloading: "Descargando…",
|
||||
stateComplete: "Completado",
|
||||
stateFailed: "Fallido",
|
||||
statePaused: "Pausado",
|
||||
stateCancelled: "Cancelado",
|
||||
stateRetrying: "Reintentando…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Sin conexión",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Nueva carpeta",
|
||||
newFolderPlaceholder: "Nombre de la carpeta",
|
||||
renameTitle: "Renombrar",
|
||||
renamePlaceholder: "Nombre nuevo",
|
||||
deleteTitle: "¿Eliminar «{name}»?",
|
||||
deleteTitleMulti: "¿Eliminar {n} elementos?",
|
||||
deleteBody: "Esta acción no se puede deshacer.",
|
||||
},
|
||||
settings: {
|
||||
title: "Configuración",
|
||||
navGeneral: "General",
|
||||
navAppearance: "Apariencia",
|
||||
navConnections: "Conexiones",
|
||||
navHistory: "Historial",
|
||||
navSecurity: "Seguridad",
|
||||
navTransfers: "Transferencias",
|
||||
navAbout: "Acerca de",
|
||||
|
||||
language: "Idioma",
|
||||
languageHint: "Idioma de la interfaz.",
|
||||
confirmDelete: "Confirmar antes de eliminar",
|
||||
confirmDeleteHint: "Preguntar antes de eliminar archivos locales o remotos.",
|
||||
startupFolder: "Carpeta de inicio",
|
||||
startupFolderHint: "El panel local se abrirá aquí al iniciar Wherry.",
|
||||
homeDirectory: "Directorio personal",
|
||||
useCurrentFolder: "Usar carpeta actual",
|
||||
resetToHome: "Restablecer a inicio",
|
||||
|
||||
appearanceHint: "Elige qué paneles son visibles en la ventana principal.",
|
||||
toolbar: "Barra de herramientas",
|
||||
sidebar: "Barra lateral",
|
||||
statusBar: "Barra de estado",
|
||||
transferQueue: "Cola de transferencias",
|
||||
|
||||
sites: "Sitios guardados",
|
||||
noSites: "Aún no hay sitios guardados",
|
||||
deleteSiteHover: "Eliminar este sitio",
|
||||
removedSite: "Sitio «{name}» eliminado",
|
||||
bookmarksTitle: "Marcadores",
|
||||
noBookmarks: "Aún no hay marcadores",
|
||||
bookmarkRemoved: "Marcador eliminado",
|
||||
|
||||
historyTitle: "Historial de conexiones",
|
||||
historyEmpty: "Aún no hay historial de conexiones",
|
||||
confirmClear: "¿Eliminar todo el historial de conexiones? Esta acción no se puede deshacer.",
|
||||
clearAll: "Borrar todo el historial",
|
||||
deleteAll: "Eliminar todo",
|
||||
cleared: "Historial borrado",
|
||||
|
||||
securityHint: "Las contraseñas se almacenan en la base de datos local de Wherry.",
|
||||
noConnections: "Sin conexiones guardadas",
|
||||
forgetPasswordHover: "Olvidar contraseña guardada",
|
||||
passwordForgotten: "Contraseña olvidada",
|
||||
forgetAll: "Olvidar todas las contraseñas guardadas",
|
||||
allForgotten: "Todas las contraseñas guardadas olvidadas",
|
||||
|
||||
concurrentTitle: "Transferencias simultáneas",
|
||||
concurrentHint: "Cuántas subidas/descargas pueden ejecutarse al mismo tiempo.",
|
||||
autoRemoveTitle: "Eliminar automáticamente transferencias completadas",
|
||||
autoRemoveHint: "Elimina automáticamente las transferencias finalizadas de la cola.",
|
||||
never: "Nunca",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Versión",
|
||||
license: "Licencia",
|
||||
database: "Base de datos",
|
||||
revealInFolder: "Mostrar en carpeta",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/fr.js
Normal file
175
frontend/js/i18n/fr.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Français — traductions. Les clés manquantes sont automatiquement reprises de en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Ouvrir",
|
||||
rename: "Renommer",
|
||||
delete: "Supprimer",
|
||||
refresh: "Actualiser",
|
||||
settings: "Paramètres",
|
||||
history: "Historique",
|
||||
edit: "Modifier",
|
||||
save: "Enregistrer",
|
||||
cancel: "Annuler",
|
||||
ok: "OK",
|
||||
copyPath: "Copier le chemin",
|
||||
newFolder: "Nouveau dossier",
|
||||
upload: "Envoyer",
|
||||
download: "Télécharger",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Nouvelle connexion",
|
||||
queueLabel: "File d'attente",
|
||||
uploadQueued: "Envoi mis en file d'attente : {name}",
|
||||
downloadQueued: "Téléchargement mis en file d'attente : {name}",
|
||||
uploadQueuedMulti: "{n} envois en file d'attente",
|
||||
downloadQueuedMulti: "{n} téléchargements en file d'attente",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "Client FTP / SFTP",
|
||||
newConnection: "Nouvelle connexion",
|
||||
recentConnections: "Connexions récentes",
|
||||
savedSites: "Sites enregistrés",
|
||||
},
|
||||
connect: {
|
||||
title: "Nouvelle connexion",
|
||||
host: "Hôte",
|
||||
username: "Nom d'utilisateur",
|
||||
port: "Port",
|
||||
password: "Mot de passe",
|
||||
keyHint: "Laissez vide pour vous connecter avec une clé SSH (agent ou ~/.ssh).",
|
||||
keyFile: "Fichier de clé (facultatif)",
|
||||
browse: "Parcourir…",
|
||||
label: "Étiquette (facultatif)",
|
||||
connect: "Connecter",
|
||||
connecting: "Connexion…",
|
||||
errorHost: "L'hôte est requis.",
|
||||
errorUser: "Le nom d'utilisateur est requis.",
|
||||
errorPort: "Le port doit être un nombre compris entre 1 et 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Connexions récentes",
|
||||
noHistory: "Aucun historique de connexions",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOCAL",
|
||||
remoteLabel: "DISTANT — {host}",
|
||||
loading: "Chargement…",
|
||||
countItems: "{n} éléments",
|
||||
colName: "Nom",
|
||||
colModified: "Modifié",
|
||||
colType: "Type",
|
||||
colSize: "Taille",
|
||||
typeDir: "Dossier",
|
||||
typeFile: "Fichier",
|
||||
typeLink: "Lien",
|
||||
pathCopied: "Chemin copié",
|
||||
sidebarDrives: "DISQUES",
|
||||
sidebarBookmarks: "FAVORIS",
|
||||
sidebarRoot: "Racine",
|
||||
sidebarRemoveBookmark: "Supprimer le favori",
|
||||
bookmarkThisFolder: "Ajouter ce dossier aux favoris",
|
||||
addToBookmarks: "Ajouter aux favoris",
|
||||
alreadyBookmarked: "Déjà dans les favoris",
|
||||
bookmarkAdded: "Favori ajouté",
|
||||
uploadToServer: "Envoyer vers le serveur",
|
||||
downloadToLocal: "Télécharger en local",
|
||||
notConnected: "Non connecté",
|
||||
movingFile: "Déplacement de {name}…",
|
||||
movingFiles: "Déplacement de {n} éléments…",
|
||||
moved: "{n} éléments déplacés",
|
||||
moveFailedCount: "{n} éléments n'ont pas pu être déplacés",
|
||||
deleted: "{n} éléments supprimés",
|
||||
deleteFailedCount: "{n} éléments n'ont pas pu être supprimés",
|
||||
cannotMoveCrossConnection: "Impossible de déplacer entre différentes connexions",
|
||||
noActiveConnection: "Aucune connexion active",
|
||||
opening: "Ouverture de {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} transferts",
|
||||
doneSuffix: "{done}/{total} terminé",
|
||||
failedSuffix: "{n} échec(s)",
|
||||
empty: "Aucun transfert pour l'instant",
|
||||
stateQueued: "En attente",
|
||||
stateUploading: "Envoi…",
|
||||
stateDownloading: "Téléchargement…",
|
||||
stateComplete: "Terminé",
|
||||
stateFailed: "Échec",
|
||||
statePaused: "En pause",
|
||||
stateCancelled: "Annulé",
|
||||
stateRetrying: "Nouvelle tentative…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Non connecté",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Nouveau dossier",
|
||||
newFolderPlaceholder: "Nom du dossier",
|
||||
renameTitle: "Renommer",
|
||||
renamePlaceholder: "Nouveau nom",
|
||||
deleteTitle: "Supprimer « {name} » ?",
|
||||
deleteTitleMulti: "Supprimer {n} éléments ?",
|
||||
deleteBody: "Cette action est irréversible.",
|
||||
},
|
||||
settings: {
|
||||
title: "Paramètres",
|
||||
navGeneral: "Général",
|
||||
navAppearance: "Apparence",
|
||||
navConnections: "Connexions",
|
||||
navHistory: "Historique",
|
||||
navSecurity: "Sécurité",
|
||||
navTransfers: "Transferts",
|
||||
navAbout: "À propos",
|
||||
|
||||
language: "Langue",
|
||||
languageHint: "Langue de l'interface.",
|
||||
confirmDelete: "Confirmer avant la suppression",
|
||||
confirmDeleteHint: "Demander confirmation avant de supprimer des fichiers locaux ou distants.",
|
||||
startupFolder: "Dossier de démarrage",
|
||||
startupFolderHint: "Le panneau local s'ouvrira ici au démarrage de Wherry.",
|
||||
homeDirectory: "Dossier personnel",
|
||||
useCurrentFolder: "Utiliser le dossier actuel",
|
||||
resetToHome: "Réinitialiser au dossier personnel",
|
||||
|
||||
appearanceHint: "Choisissez les panneaux visibles dans la fenêtre principale.",
|
||||
toolbar: "Barre d'outils",
|
||||
sidebar: "Barre latérale",
|
||||
statusBar: "Barre d'état",
|
||||
transferQueue: "File d'attente des transferts",
|
||||
|
||||
sites: "Sites enregistrés",
|
||||
noSites: "Aucun site enregistré pour l'instant",
|
||||
deleteSiteHover: "Supprimer ce site",
|
||||
removedSite: "Site « {name} » supprimé",
|
||||
bookmarksTitle: "Favoris",
|
||||
noBookmarks: "Aucun favori pour l'instant",
|
||||
bookmarkRemoved: "Favori supprimé",
|
||||
|
||||
historyTitle: "Historique des connexions",
|
||||
historyEmpty: "Aucun historique de connexions",
|
||||
confirmClear: "Supprimer tout l'historique des connexions ? Cette action est irréversible.",
|
||||
clearAll: "Effacer tout l'historique",
|
||||
deleteAll: "Tout supprimer",
|
||||
cleared: "Historique effacé",
|
||||
|
||||
securityHint: "Les mots de passe sont stockés dans la base de données locale de Wherry.",
|
||||
noConnections: "Aucune connexion enregistrée",
|
||||
forgetPasswordHover: "Oublier le mot de passe enregistré",
|
||||
passwordForgotten: "Mot de passe oublié",
|
||||
forgetAll: "Oublier tous les mots de passe enregistrés",
|
||||
allForgotten: "Tous les mots de passe enregistrés oubliés",
|
||||
|
||||
concurrentTitle: "Transferts simultanés",
|
||||
concurrentHint: "Combien d'envois/téléchargements peuvent s'exécuter simultanément.",
|
||||
autoRemoveTitle: "Suppression automatique des transferts terminés",
|
||||
autoRemoveHint: "Efface automatiquement les transferts terminés de la file d'attente.",
|
||||
never: "Jamais",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Version",
|
||||
license: "Licence",
|
||||
database: "Base de données",
|
||||
revealInFolder: "Afficher dans le dossier",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/it.js
Normal file
175
frontend/js/i18n/it.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Italiano — traduzioni. Le chiavi mancanti vengono automaticamente prese da en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Apri",
|
||||
rename: "Rinomina",
|
||||
delete: "Elimina",
|
||||
refresh: "Aggiorna",
|
||||
settings: "Impostazioni",
|
||||
history: "Cronologia",
|
||||
edit: "Modifica",
|
||||
save: "Salva",
|
||||
cancel: "Annulla",
|
||||
ok: "OK",
|
||||
copyPath: "Copia percorso",
|
||||
newFolder: "Nuova cartella",
|
||||
upload: "Carica",
|
||||
download: "Scarica",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Nuova connessione",
|
||||
queueLabel: "Coda",
|
||||
uploadQueued: "Caricamento in coda: {name}",
|
||||
downloadQueued: "Download in coda: {name}",
|
||||
uploadQueuedMulti: "{n} caricamenti in coda",
|
||||
downloadQueuedMulti: "{n} download in coda",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "Client FTP / SFTP",
|
||||
newConnection: "Nuova connessione",
|
||||
recentConnections: "Connessioni recenti",
|
||||
savedSites: "Siti salvati",
|
||||
},
|
||||
connect: {
|
||||
title: "Nuova connessione",
|
||||
host: "Host",
|
||||
username: "Nome utente",
|
||||
port: "Porta",
|
||||
password: "Password",
|
||||
keyHint: "Lascia vuoto per accedere con una chiave SSH (agente o ~/.ssh).",
|
||||
keyFile: "File chiave (opzionale)",
|
||||
browse: "Sfoglia…",
|
||||
label: "Etichetta (opzionale)",
|
||||
connect: "Connetti",
|
||||
connecting: "Connessione…",
|
||||
errorHost: "L'host è obbligatorio.",
|
||||
errorUser: "Il nome utente è obbligatorio.",
|
||||
errorPort: "La porta deve essere un numero compreso tra 1 e 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Connessioni recenti",
|
||||
noHistory: "Nessuna cronologia connessioni",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOCALE",
|
||||
remoteLabel: "REMOTO — {host}",
|
||||
loading: "Caricamento…",
|
||||
countItems: "{n} elementi",
|
||||
colName: "Nome",
|
||||
colModified: "Modificato",
|
||||
colType: "Tipo",
|
||||
colSize: "Dimensione",
|
||||
typeDir: "Cartella",
|
||||
typeFile: "File",
|
||||
typeLink: "Collegamento",
|
||||
pathCopied: "Percorso copiato",
|
||||
sidebarDrives: "UNITÀ",
|
||||
sidebarBookmarks: "PREFERITI",
|
||||
sidebarRoot: "Radice",
|
||||
sidebarRemoveBookmark: "Rimuovi preferito",
|
||||
bookmarkThisFolder: "Aggiungi cartella ai preferiti",
|
||||
addToBookmarks: "Aggiungi ai preferiti",
|
||||
alreadyBookmarked: "Già nei preferiti",
|
||||
bookmarkAdded: "Preferito aggiunto",
|
||||
uploadToServer: "Carica sul server",
|
||||
downloadToLocal: "Scarica in locale",
|
||||
notConnected: "Non connesso",
|
||||
movingFile: "Spostamento {name}…",
|
||||
movingFiles: "Spostamento di {n} elementi…",
|
||||
moved: "{n} elementi spostati",
|
||||
moveFailedCount: "{n} elementi non spostati",
|
||||
deleted: "{n} elementi eliminati",
|
||||
deleteFailedCount: "{n} elementi non eliminati",
|
||||
cannotMoveCrossConnection: "Impossibile spostare tra connessioni diverse",
|
||||
noActiveConnection: "Nessuna connessione attiva",
|
||||
opening: "Apertura di {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} trasferimenti",
|
||||
doneSuffix: "{done}/{total} completato",
|
||||
failedSuffix: "{n} falliti",
|
||||
empty: "Nessun trasferimento ancora",
|
||||
stateQueued: "In coda",
|
||||
stateUploading: "Caricamento…",
|
||||
stateDownloading: "Download…",
|
||||
stateComplete: "Completato",
|
||||
stateFailed: "Non riuscito",
|
||||
statePaused: "In pausa",
|
||||
stateCancelled: "Annullato",
|
||||
stateRetrying: "Nuovo tentativo…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Non connesso",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Nuova cartella",
|
||||
newFolderPlaceholder: "Nome cartella",
|
||||
renameTitle: "Rinomina",
|
||||
renamePlaceholder: "Nuovo nome",
|
||||
deleteTitle: "Eliminare «{name}»?",
|
||||
deleteTitleMulti: "Eliminare {n} elementi?",
|
||||
deleteBody: "Questa azione non può essere annullata.",
|
||||
},
|
||||
settings: {
|
||||
title: "Impostazioni",
|
||||
navGeneral: "Generale",
|
||||
navAppearance: "Aspetto",
|
||||
navConnections: "Connessioni",
|
||||
navHistory: "Cronologia",
|
||||
navSecurity: "Sicurezza",
|
||||
navTransfers: "Trasferimenti",
|
||||
navAbout: "Informazioni",
|
||||
|
||||
language: "Lingua",
|
||||
languageHint: "Lingua dell'interfaccia.",
|
||||
confirmDelete: "Conferma prima di eliminare",
|
||||
confirmDeleteHint: "Chiedi conferma prima di eliminare file locali o remoti.",
|
||||
startupFolder: "Cartella di avvio",
|
||||
startupFolderHint: "Il pannello locale si aprirà qui all'avvio di Wherry.",
|
||||
homeDirectory: "Cartella Home",
|
||||
useCurrentFolder: "Usa cartella corrente",
|
||||
resetToHome: "Ripristina su Home",
|
||||
|
||||
appearanceHint: "Scegli quali pannelli sono visibili nella finestra principale.",
|
||||
toolbar: "Barra degli strumenti",
|
||||
sidebar: "Barra laterale",
|
||||
statusBar: "Barra di stato",
|
||||
transferQueue: "Coda dei trasferimenti",
|
||||
|
||||
sites: "Siti salvati",
|
||||
noSites: "Nessun sito salvato",
|
||||
deleteSiteHover: "Elimina questo sito",
|
||||
removedSite: "Sito «{name}» rimosso",
|
||||
bookmarksTitle: "Preferiti",
|
||||
noBookmarks: "Nessun preferito",
|
||||
bookmarkRemoved: "Preferito rimosso",
|
||||
|
||||
historyTitle: "Cronologia connessioni",
|
||||
historyEmpty: "Nessuna cronologia connessioni",
|
||||
confirmClear: "Eliminare tutta la cronologia delle connessioni? Questa azione non può essere annullata.",
|
||||
clearAll: "Cancella tutta la cronologia",
|
||||
deleteAll: "Elimina tutto",
|
||||
cleared: "Cronologia cancellata",
|
||||
|
||||
securityHint: "Le password sono conservate nel database locale di Wherry.",
|
||||
noConnections: "Nessuna connessione salvata",
|
||||
forgetPasswordHover: "Dimentica password salvata",
|
||||
passwordForgotten: "Password dimenticata",
|
||||
forgetAll: "Dimentica tutte le password salvate",
|
||||
allForgotten: "Tutte le password salvate dimenticate",
|
||||
|
||||
concurrentTitle: "Trasferimenti simultanei",
|
||||
concurrentHint: "Quanti caricamenti/download possono essere eseguiti contemporaneamente.",
|
||||
autoRemoveTitle: "Rimozione automatica trasferimenti completati",
|
||||
autoRemoveHint: "Rimuove automaticamente i trasferimenti terminati dalla coda.",
|
||||
never: "Mai",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Versione",
|
||||
license: "Licenza",
|
||||
database: "Database",
|
||||
revealInFolder: "Mostra nella cartella",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/ja.js
Normal file
175
frontend/js/i18n/ja.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// 日本語 — 翻訳。不足しているキーは自動的に en.js から取得されます。
|
||||
export default {
|
||||
common: {
|
||||
open: "開く",
|
||||
rename: "名前を変更",
|
||||
delete: "削除",
|
||||
refresh: "更新",
|
||||
settings: "設定",
|
||||
history: "履歴",
|
||||
edit: "編集",
|
||||
save: "保存",
|
||||
cancel: "キャンセル",
|
||||
ok: "OK",
|
||||
copyPath: "パスをコピー",
|
||||
newFolder: "新規フォルダ",
|
||||
upload: "アップロード",
|
||||
download: "ダウンロード",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "新規接続",
|
||||
queueLabel: "キュー",
|
||||
uploadQueued: "アップロードをキューに追加しました: {name}",
|
||||
downloadQueued: "ダウンロードをキューに追加しました: {name}",
|
||||
uploadQueuedMulti: "{n} 件のアップロードをキューに追加",
|
||||
downloadQueuedMulti: "{n} 件のダウンロードをキューに追加",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP クライアント",
|
||||
newConnection: "新規接続",
|
||||
recentConnections: "最近の接続",
|
||||
savedSites: "保存済みサイト",
|
||||
},
|
||||
connect: {
|
||||
title: "新規接続",
|
||||
host: "ホスト",
|
||||
username: "ユーザー名",
|
||||
port: "ポート",
|
||||
password: "パスワード",
|
||||
keyHint: "SSH 鍵でサインインする場合は空欄のままにしてください(エージェントまたは ~/.ssh)。",
|
||||
keyFile: "鍵ファイル(任意)",
|
||||
browse: "参照…",
|
||||
label: "ラベル(任意)",
|
||||
connect: "接続",
|
||||
connecting: "接続中…",
|
||||
errorHost: "ホストを入力してください。",
|
||||
errorUser: "ユーザー名を入力してください。",
|
||||
errorPort: "ポートは 1〜65535 の数値で指定してください。",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "最近の接続",
|
||||
noHistory: "接続履歴はまだありません",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "ローカル",
|
||||
remoteLabel: "リモート — {host}",
|
||||
loading: "読み込み中…",
|
||||
countItems: "{n} 件",
|
||||
colName: "名前",
|
||||
colModified: "更新日時",
|
||||
colType: "種類",
|
||||
colSize: "サイズ",
|
||||
typeDir: "フォルダ",
|
||||
typeFile: "ファイル",
|
||||
typeLink: "リンク",
|
||||
pathCopied: "パスをコピーしました",
|
||||
sidebarDrives: "ドライブ",
|
||||
sidebarBookmarks: "ブックマーク",
|
||||
sidebarRoot: "ルート",
|
||||
sidebarRemoveBookmark: "ブックマークを削除",
|
||||
bookmarkThisFolder: "このフォルダをブックマーク",
|
||||
addToBookmarks: "ブックマークに追加",
|
||||
alreadyBookmarked: "既にブックマーク済みです",
|
||||
bookmarkAdded: "ブックマークを追加しました",
|
||||
uploadToServer: "サーバーへアップロード",
|
||||
downloadToLocal: "ローカルにダウンロード",
|
||||
notConnected: "未接続",
|
||||
movingFile: "{name} を移動中…",
|
||||
movingFiles: "{n} 件のアイテムを移動中…",
|
||||
moved: "{n} 件のアイテムを移動しました",
|
||||
moveFailedCount: "{n} 件のアイテムの移動に失敗しました",
|
||||
deleted: "{n} 件のアイテムを削除しました",
|
||||
deleteFailedCount: "{n} 件のアイテムの削除に失敗しました",
|
||||
cannotMoveCrossConnection: "異なる接続間では移動できません",
|
||||
noActiveConnection: "アクティブな接続がありません",
|
||||
opening: "{name} を開いています…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} 件の転送",
|
||||
doneSuffix: "{done}/{total} 完了",
|
||||
failedSuffix: "失敗 {n} 件",
|
||||
empty: "転送はまだありません",
|
||||
stateQueued: "待機中",
|
||||
stateUploading: "アップロード中…",
|
||||
stateDownloading: "ダウンロード中…",
|
||||
stateComplete: "完了",
|
||||
stateFailed: "失敗",
|
||||
statePaused: "一時停止",
|
||||
stateCancelled: "キャンセル済み",
|
||||
stateRetrying: "再試行中…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "未接続",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "新規フォルダ",
|
||||
newFolderPlaceholder: "フォルダ名",
|
||||
renameTitle: "名前を変更",
|
||||
renamePlaceholder: "新しい名前",
|
||||
deleteTitle: "「{name}」を削除しますか?",
|
||||
deleteTitleMulti: "{n} 件のアイテムを削除しますか?",
|
||||
deleteBody: "この操作は元に戻せません。",
|
||||
},
|
||||
settings: {
|
||||
title: "設定",
|
||||
navGeneral: "一般",
|
||||
navAppearance: "外観",
|
||||
navConnections: "接続",
|
||||
navHistory: "履歴",
|
||||
navSecurity: "セキュリティ",
|
||||
navTransfers: "転送",
|
||||
navAbout: "情報",
|
||||
|
||||
language: "言語",
|
||||
languageHint: "インターフェースの言語。",
|
||||
confirmDelete: "削除前に確認する",
|
||||
confirmDeleteHint: "ローカルまたはリモートのファイルを削除する前に確認ダイアログを表示します。",
|
||||
startupFolder: "起動時のフォルダ",
|
||||
startupFolderHint: "Wherry 起動時にローカルパネルがここで開きます。",
|
||||
homeDirectory: "ホームディレクトリ",
|
||||
useCurrentFolder: "現在のフォルダを使用",
|
||||
resetToHome: "ホームにリセット",
|
||||
|
||||
appearanceHint: "メインウィンドウに表示するパネルを選択します。",
|
||||
toolbar: "ツールバー",
|
||||
sidebar: "サイドバー",
|
||||
statusBar: "ステータスバー",
|
||||
transferQueue: "転送キュー",
|
||||
|
||||
sites: "保存済みサイト",
|
||||
noSites: "保存済みのサイトはまだありません",
|
||||
deleteSiteHover: "このサイトを削除",
|
||||
removedSite: "サイト「{name}」を削除しました",
|
||||
bookmarksTitle: "ブックマーク",
|
||||
noBookmarks: "ブックマークはまだありません",
|
||||
bookmarkRemoved: "ブックマークを削除しました",
|
||||
|
||||
historyTitle: "接続履歴",
|
||||
historyEmpty: "接続履歴はまだありません",
|
||||
confirmClear: "接続履歴をすべて削除しますか?この操作は元に戻せません。",
|
||||
clearAll: "すべての履歴を消去",
|
||||
deleteAll: "すべて削除",
|
||||
cleared: "履歴を消去しました",
|
||||
|
||||
securityHint: "パスワードは Wherry のローカルデータベースに保存されます。",
|
||||
noConnections: "保存済みの接続はありません",
|
||||
forgetPasswordHover: "保存済みのパスワードを削除",
|
||||
passwordForgotten: "パスワードを削除しました",
|
||||
forgetAll: "保存済みのパスワードをすべて削除",
|
||||
allForgotten: "保存済みのパスワードをすべて削除しました",
|
||||
|
||||
concurrentTitle: "同時転送数",
|
||||
concurrentHint: "同時に実行できるアップロード/ダウンロードの数。",
|
||||
autoRemoveTitle: "完了した転送を自動的に削除",
|
||||
autoRemoveHint: "完了した転送をキューから自動的に消去します。",
|
||||
never: "しない",
|
||||
tenSec: "10秒",
|
||||
thirtySec: "30秒",
|
||||
oneMin: "1分",
|
||||
|
||||
version: "バージョン",
|
||||
license: "ライセンス",
|
||||
database: "データベース",
|
||||
revealInFolder: "フォルダで表示",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/ko.js
Normal file
175
frontend/js/i18n/ko.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// 한국어 — 번역. 누락된 키는 자동으로 en.js에서 가져옵니다.
|
||||
export default {
|
||||
common: {
|
||||
open: "열기",
|
||||
rename: "이름 바꾸기",
|
||||
delete: "삭제",
|
||||
refresh: "새로고침",
|
||||
settings: "설정",
|
||||
history: "기록",
|
||||
edit: "편집",
|
||||
save: "저장",
|
||||
cancel: "취소",
|
||||
ok: "확인",
|
||||
copyPath: "경로 복사",
|
||||
newFolder: "새 폴더",
|
||||
upload: "업로드",
|
||||
download: "다운로드",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "새 연결",
|
||||
queueLabel: "대기열",
|
||||
uploadQueued: "업로드 대기열에 추가됨: {name}",
|
||||
downloadQueued: "다운로드 대기열에 추가됨: {name}",
|
||||
uploadQueuedMulti: "{n}개 업로드 대기열에 추가",
|
||||
downloadQueuedMulti: "{n}개 다운로드 대기열에 추가",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP 클라이언트",
|
||||
newConnection: "새 연결",
|
||||
recentConnections: "최근 연결",
|
||||
savedSites: "저장된 사이트",
|
||||
},
|
||||
connect: {
|
||||
title: "새 연결",
|
||||
host: "호스트",
|
||||
username: "사용자 이름",
|
||||
port: "포트",
|
||||
password: "비밀번호",
|
||||
keyHint: "SSH 키로 로그인하려면 비워 두세요(에이전트 또는 ~/.ssh).",
|
||||
keyFile: "키 파일(선택 사항)",
|
||||
browse: "찾아보기…",
|
||||
label: "라벨(선택 사항)",
|
||||
connect: "연결",
|
||||
connecting: "연결 중…",
|
||||
errorHost: "호스트를 입력해야 합니다.",
|
||||
errorUser: "사용자 이름을 입력해야 합니다.",
|
||||
errorPort: "포트는 1에서 65535 사이의 숫자여야 합니다.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "최근 연결",
|
||||
noHistory: "연결 기록이 아직 없습니다",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "로컬",
|
||||
remoteLabel: "원격 — {host}",
|
||||
loading: "로딩 중…",
|
||||
countItems: "{n}개 항목",
|
||||
colName: "이름",
|
||||
colModified: "수정됨",
|
||||
colType: "유형",
|
||||
colSize: "크기",
|
||||
typeDir: "폴더",
|
||||
typeFile: "파일",
|
||||
typeLink: "링크",
|
||||
pathCopied: "경로가 복사되었습니다",
|
||||
sidebarDrives: "드라이브",
|
||||
sidebarBookmarks: "북마크",
|
||||
sidebarRoot: "루트",
|
||||
sidebarRemoveBookmark: "북마크 제거",
|
||||
bookmarkThisFolder: "이 폴더 북마크",
|
||||
addToBookmarks: "북마크에 추가",
|
||||
alreadyBookmarked: "이미 북마크됨",
|
||||
bookmarkAdded: "북마크 추가됨",
|
||||
uploadToServer: "서버에 업로드",
|
||||
downloadToLocal: "로컬에 다운로드",
|
||||
notConnected: "연결 안 됨",
|
||||
movingFile: "{name} 이동 중…",
|
||||
movingFiles: "{n}개 항목 이동 중…",
|
||||
moved: "{n}개 항목 이동됨",
|
||||
moveFailedCount: "{n}개 항목 이동 실패",
|
||||
deleted: "{n}개 항목 삭제됨",
|
||||
deleteFailedCount: "{n}개 항목 삭제 실패",
|
||||
cannotMoveCrossConnection: "서로 다른 연결 간에는 이동할 수 없습니다",
|
||||
noActiveConnection: "활성 연결이 없습니다",
|
||||
opening: "{name} 여는 중…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n}개 전송",
|
||||
doneSuffix: "{done}/{total} 완료",
|
||||
failedSuffix: "실패 {n}개",
|
||||
empty: "아직 전송이 없습니다",
|
||||
stateQueued: "대기 중",
|
||||
stateUploading: "업로드 중…",
|
||||
stateDownloading: "다운로드 중…",
|
||||
stateComplete: "완료",
|
||||
stateFailed: "실패",
|
||||
statePaused: "일시 중지",
|
||||
stateCancelled: "취소됨",
|
||||
stateRetrying: "재시도 중…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "연결 안 됨",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "새 폴더",
|
||||
newFolderPlaceholder: "폴더 이름",
|
||||
renameTitle: "이름 바꾸기",
|
||||
renamePlaceholder: "새 이름",
|
||||
deleteTitle: "\"{name}\"을(를) 삭제하시겠습니까?",
|
||||
deleteTitleMulti: "{n}개 항목을 삭제하시겠습니까?",
|
||||
deleteBody: "이 작업은 되돌릴 수 없습니다.",
|
||||
},
|
||||
settings: {
|
||||
title: "설정",
|
||||
navGeneral: "일반",
|
||||
navAppearance: "외관",
|
||||
navConnections: "연결",
|
||||
navHistory: "기록",
|
||||
navSecurity: "보안",
|
||||
navTransfers: "전송",
|
||||
navAbout: "정보",
|
||||
|
||||
language: "언어",
|
||||
languageHint: "인터페이스 언어.",
|
||||
confirmDelete: "삭제 전 확인",
|
||||
confirmDeleteHint: "로컬 또는 원격 파일을 삭제하기 전에 확인 대화 상자를 표시합니다.",
|
||||
startupFolder: "시작 폴더",
|
||||
startupFolderHint: "Wherry을 시작하면 로컬 패널이 여기에서 열립니다.",
|
||||
homeDirectory: "홈 디렉터리",
|
||||
useCurrentFolder: "현재 폴더 사용",
|
||||
resetToHome: "홈으로 재설정",
|
||||
|
||||
appearanceHint: "메인 창에 표시할 패널을 선택하세요.",
|
||||
toolbar: "도구 모음",
|
||||
sidebar: "사이드바",
|
||||
statusBar: "상태 표시줄",
|
||||
transferQueue: "전송 대기열",
|
||||
|
||||
sites: "저장된 사이트",
|
||||
noSites: "저장된 사이트가 아직 없습니다",
|
||||
deleteSiteHover: "이 사이트 삭제",
|
||||
removedSite: "사이트 \"{name}\" 삭제됨",
|
||||
bookmarksTitle: "북마크",
|
||||
noBookmarks: "북마크가 아직 없습니다",
|
||||
bookmarkRemoved: "북마크 제거됨",
|
||||
|
||||
historyTitle: "연결 기록",
|
||||
historyEmpty: "연결 기록이 아직 없습니다",
|
||||
confirmClear: "모든 연결 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
clearAll: "전체 기록 지우기",
|
||||
deleteAll: "모두 삭제",
|
||||
cleared: "기록이 지워졌습니다",
|
||||
|
||||
securityHint: "비밀번호는 Wherry의 로컬 데이터베이스에 저장됩니다.",
|
||||
noConnections: "저장된 연결이 없습니다",
|
||||
forgetPasswordHover: "저장된 비밀번호 삭제",
|
||||
passwordForgotten: "비밀번호가 삭제되었습니다",
|
||||
forgetAll: "저장된 모든 비밀번호 삭제",
|
||||
allForgotten: "저장된 모든 비밀번호가 삭제되었습니다",
|
||||
|
||||
concurrentTitle: "동시 전송",
|
||||
concurrentHint: "동시에 실행할 수 있는 업로드/다운로드 수입니다.",
|
||||
autoRemoveTitle: "완료된 전송 자동 제거",
|
||||
autoRemoveHint: "완료된 전송을 대기열에서 자동으로 지웁니다.",
|
||||
never: "안 함",
|
||||
tenSec: "10초",
|
||||
thirtySec: "30초",
|
||||
oneMin: "1분",
|
||||
|
||||
version: "버전",
|
||||
license: "라이선스",
|
||||
database: "데이터베이스",
|
||||
revealInFolder: "폴더에서 보기",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/pl.js
Normal file
175
frontend/js/i18n/pl.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Polski — tłumaczenia. Brakujące klucze są automatycznie pobierane z en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Otwórz",
|
||||
rename: "Zmień nazwę",
|
||||
delete: "Usuń",
|
||||
refresh: "Odśwież",
|
||||
settings: "Ustawienia",
|
||||
history: "Historia",
|
||||
edit: "Edytuj",
|
||||
save: "Zapisz",
|
||||
cancel: "Anuluj",
|
||||
ok: "OK",
|
||||
copyPath: "Kopiuj ścieżkę",
|
||||
newFolder: "Nowy folder",
|
||||
upload: "Prześlij",
|
||||
download: "Pobierz",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Nowe połączenie",
|
||||
queueLabel: "Kolejka",
|
||||
uploadQueued: "Wysyłanie dodane do kolejki: {name}",
|
||||
downloadQueued: "Pobieranie dodane do kolejki: {name}",
|
||||
uploadQueuedMulti: "{n} wysyłań w kolejce",
|
||||
downloadQueuedMulti: "{n} pobrań w kolejce",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "Klient FTP / SFTP",
|
||||
newConnection: "Nowe połączenie",
|
||||
recentConnections: "Ostatnie połączenia",
|
||||
savedSites: "Zapisane serwery",
|
||||
},
|
||||
connect: {
|
||||
title: "Nowe połączenie",
|
||||
host: "Host",
|
||||
username: "Nazwa użytkownika",
|
||||
port: "Port",
|
||||
password: "Hasło",
|
||||
keyHint: "Pozostaw puste, aby zalogować się kluczem SSH (agent lub ~/.ssh).",
|
||||
keyFile: "Plik klucza (opcjonalnie)",
|
||||
browse: "Przeglądaj…",
|
||||
label: "Etykieta (opcjonalnie)",
|
||||
connect: "Połącz",
|
||||
connecting: "Łączenie…",
|
||||
errorHost: "Host jest wymagany.",
|
||||
errorUser: "Nazwa użytkownika jest wymagana.",
|
||||
errorPort: "Port musi być liczbą od 1 do 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Ostatnie połączenia",
|
||||
noHistory: "Brak historii połączeń",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOKALNE",
|
||||
remoteLabel: "ZDALNE — {host}",
|
||||
loading: "Ładowanie…",
|
||||
countItems: "{n} elementów",
|
||||
colName: "Nazwa",
|
||||
colModified: "Zmodyfikowano",
|
||||
colType: "Typ",
|
||||
colSize: "Rozmiar",
|
||||
typeDir: "Folder",
|
||||
typeFile: "Plik",
|
||||
typeLink: "Link",
|
||||
pathCopied: "Skopiowano ścieżkę",
|
||||
sidebarDrives: "DYSKI",
|
||||
sidebarBookmarks: "ZAKŁADKI",
|
||||
sidebarRoot: "Główny",
|
||||
sidebarRemoveBookmark: "Usuń zakładkę",
|
||||
bookmarkThisFolder: "Dodaj folder do zakładek",
|
||||
addToBookmarks: "Dodaj do zakładek",
|
||||
alreadyBookmarked: "Już w zakładkach",
|
||||
bookmarkAdded: "Dodano zakładkę",
|
||||
uploadToServer: "Wyślij na serwer",
|
||||
downloadToLocal: "Pobierz do lokalnego",
|
||||
notConnected: "Brak połączenia",
|
||||
movingFile: "Przenoszenie {name}…",
|
||||
movingFiles: "Przenoszenie {n} elementów…",
|
||||
moved: "Przeniesiono {n} elementów",
|
||||
moveFailedCount: "Nie udało się przenieść {n} elementów",
|
||||
deleted: "Usunięto {n} elementów",
|
||||
deleteFailedCount: "Nie udało się usunąć {n} elementów",
|
||||
cannotMoveCrossConnection: "Nie można przenosić między różnymi połączeniami",
|
||||
noActiveConnection: "Brak aktywnego połączenia",
|
||||
opening: "Otwieranie {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} transferów",
|
||||
doneSuffix: "{done}/{total} gotowe",
|
||||
failedSuffix: "Błędów: {n}",
|
||||
empty: "Brak transferów",
|
||||
stateQueued: "W kolejce",
|
||||
stateUploading: "Wysyłanie…",
|
||||
stateDownloading: "Pobieranie…",
|
||||
stateComplete: "Gotowe",
|
||||
stateFailed: "Niepowodzenie",
|
||||
statePaused: "Wstrzymane",
|
||||
stateCancelled: "Anulowane",
|
||||
stateRetrying: "Ponawianie…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Brak połączenia",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Nowy folder",
|
||||
newFolderPlaceholder: "Nazwa folderu",
|
||||
renameTitle: "Zmień nazwę",
|
||||
renamePlaceholder: "Nowa nazwa",
|
||||
deleteTitle: "Usunąć «{name}»?",
|
||||
deleteTitleMulti: "Usunąć {n} elementów?",
|
||||
deleteBody: "Tej operacji nie można cofnąć.",
|
||||
},
|
||||
settings: {
|
||||
title: "Ustawienia",
|
||||
navGeneral: "Ogólne",
|
||||
navAppearance: "Wygląd",
|
||||
navConnections: "Połączenia",
|
||||
navHistory: "Historia",
|
||||
navSecurity: "Bezpieczeństwo",
|
||||
navTransfers: "Transfery",
|
||||
navAbout: "O programie",
|
||||
|
||||
language: "Język",
|
||||
languageHint: "Język interfejsu.",
|
||||
confirmDelete: "Potwierdzaj przed usunięciem",
|
||||
confirmDeleteHint: "Pytaj przed usunięciem plików lokalnych lub zdalnych.",
|
||||
startupFolder: "Folder startowy",
|
||||
startupFolderHint: "Panel lokalny otworzy się tutaj po uruchomieniu Wherry.",
|
||||
homeDirectory: "Katalog domowy",
|
||||
useCurrentFolder: "Użyj bieżącego folderu",
|
||||
resetToHome: "Przywróć domyślny",
|
||||
|
||||
appearanceHint: "Wybierz, które panele są widoczne w oknie głównym.",
|
||||
toolbar: "Pasek narzędzi",
|
||||
sidebar: "Pasek boczny",
|
||||
statusBar: "Pasek stanu",
|
||||
transferQueue: "Kolejka transferów",
|
||||
|
||||
sites: "Zapisane serwery",
|
||||
noSites: "Brak zapisanych serwerów",
|
||||
deleteSiteHover: "Usuń ten serwer",
|
||||
removedSite: "Serwer «{name}» usunięty",
|
||||
bookmarksTitle: "Zakładki",
|
||||
noBookmarks: "Brak zakładek",
|
||||
bookmarkRemoved: "Usunięto zakładkę",
|
||||
|
||||
historyTitle: "Historia połączeń",
|
||||
historyEmpty: "Brak historii połączeń",
|
||||
confirmClear: "Usunąć całą historię połączeń? Tej operacji nie można cofnąć.",
|
||||
clearAll: "Wyczyść całą historię",
|
||||
deleteAll: "Usuń wszystko",
|
||||
cleared: "Historia wyczyszczona",
|
||||
|
||||
securityHint: "Hasła są przechowywane w lokalnej bazie danych Wherry.",
|
||||
noConnections: "Brak zapisanych połączeń",
|
||||
forgetPasswordHover: "Zapomnij zapisane hasło",
|
||||
passwordForgotten: "Zapomniano hasło",
|
||||
forgetAll: "Zapomnij wszystkie zapisane hasła",
|
||||
allForgotten: "Zapomniano wszystkie zapisane hasła",
|
||||
|
||||
concurrentTitle: "Równoczesne transfery",
|
||||
concurrentHint: "Ile wysyłek/pobrań może działać jednocześnie.",
|
||||
autoRemoveTitle: "Automatyczne usuwanie ukończonych transferów",
|
||||
autoRemoveHint: "Automatycznie usuwa zakończone transfery z kolejki.",
|
||||
never: "Nigdy",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Wersja",
|
||||
license: "Licencja",
|
||||
database: "Baza danych",
|
||||
revealInFolder: "Pokaż w folderze",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/pt.js
Normal file
175
frontend/js/i18n/pt.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Português — traduções. As chaves ausentes são automaticamente obtidas do en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Abrir",
|
||||
rename: "Renomear",
|
||||
delete: "Excluir",
|
||||
refresh: "Atualizar",
|
||||
settings: "Configurações",
|
||||
history: "Histórico",
|
||||
edit: "Editar",
|
||||
save: "Salvar",
|
||||
cancel: "Cancelar",
|
||||
ok: "OK",
|
||||
copyPath: "Copiar caminho",
|
||||
newFolder: "Nova pasta",
|
||||
upload: "Enviar",
|
||||
download: "Baixar",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Nova conexão",
|
||||
queueLabel: "Fila",
|
||||
uploadQueued: "Envio na fila: {name}",
|
||||
downloadQueued: "Download na fila: {name}",
|
||||
uploadQueuedMulti: "{n} envios na fila",
|
||||
downloadQueuedMulti: "{n} downloads na fila",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "Cliente FTP / SFTP",
|
||||
newConnection: "Nova conexão",
|
||||
recentConnections: "Conexões recentes",
|
||||
savedSites: "Sites salvos",
|
||||
},
|
||||
connect: {
|
||||
title: "Nova conexão",
|
||||
host: "Host",
|
||||
username: "Usuário",
|
||||
port: "Porta",
|
||||
password: "Senha",
|
||||
keyHint: "Deixe em branco para entrar com uma chave SSH (agente ou ~/.ssh).",
|
||||
keyFile: "Arquivo de chave (opcional)",
|
||||
browse: "Procurar…",
|
||||
label: "Rótulo (opcional)",
|
||||
connect: "Conectar",
|
||||
connecting: "Conectando…",
|
||||
errorHost: "O host é obrigatório.",
|
||||
errorUser: "O nome de usuário é obrigatório.",
|
||||
errorPort: "A porta deve ser um número entre 1 e 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Conexões recentes",
|
||||
noHistory: "Nenhum histórico de conexões ainda",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "LOCAL",
|
||||
remoteLabel: "REMOTO — {host}",
|
||||
loading: "Carregando…",
|
||||
countItems: "{n} itens",
|
||||
colName: "Nome",
|
||||
colModified: "Modificado",
|
||||
colType: "Tipo",
|
||||
colSize: "Tamanho",
|
||||
typeDir: "Pasta",
|
||||
typeFile: "Arquivo",
|
||||
typeLink: "Link",
|
||||
pathCopied: "Caminho copiado",
|
||||
sidebarDrives: "UNIDADES",
|
||||
sidebarBookmarks: "FAVORITOS",
|
||||
sidebarRoot: "Raiz",
|
||||
sidebarRemoveBookmark: "Remover favorito",
|
||||
bookmarkThisFolder: "Adicionar pasta aos favoritos",
|
||||
addToBookmarks: "Adicionar aos favoritos",
|
||||
alreadyBookmarked: "Já está nos favoritos",
|
||||
bookmarkAdded: "Favorito adicionado",
|
||||
uploadToServer: "Enviar para o servidor",
|
||||
downloadToLocal: "Baixar para local",
|
||||
notConnected: "Não conectado",
|
||||
movingFile: "Movendo {name}…",
|
||||
movingFiles: "Movendo {n} itens…",
|
||||
moved: "{n} itens movidos",
|
||||
moveFailedCount: "{n} itens falharam ao mover",
|
||||
deleted: "{n} itens excluídos",
|
||||
deleteFailedCount: "{n} itens falharam ao excluir",
|
||||
cannotMoveCrossConnection: "Não é possível mover entre conexões diferentes",
|
||||
noActiveConnection: "Nenhuma conexão ativa",
|
||||
opening: "Abrindo {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} transferências",
|
||||
doneSuffix: "{done}/{total} concluído",
|
||||
failedSuffix: "{n} falharam",
|
||||
empty: "Nenhuma transferência ainda",
|
||||
stateQueued: "Na fila",
|
||||
stateUploading: "Enviando…",
|
||||
stateDownloading: "Baixando…",
|
||||
stateComplete: "Concluído",
|
||||
stateFailed: "Falhou",
|
||||
statePaused: "Pausado",
|
||||
stateCancelled: "Cancelado",
|
||||
stateRetrying: "Tentando novamente…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Não conectado",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Nova pasta",
|
||||
newFolderPlaceholder: "Nome da pasta",
|
||||
renameTitle: "Renomear",
|
||||
renamePlaceholder: "Novo nome",
|
||||
deleteTitle: "Excluir «{name}»?",
|
||||
deleteTitleMulti: "Excluir {n} itens?",
|
||||
deleteBody: "Esta ação não pode ser desfeita.",
|
||||
},
|
||||
settings: {
|
||||
title: "Configurações",
|
||||
navGeneral: "Geral",
|
||||
navAppearance: "Aparência",
|
||||
navConnections: "Conexões",
|
||||
navHistory: "Histórico",
|
||||
navSecurity: "Segurança",
|
||||
navTransfers: "Transferências",
|
||||
navAbout: "Sobre",
|
||||
|
||||
language: "Idioma",
|
||||
languageHint: "Idioma da interface.",
|
||||
confirmDelete: "Confirmar antes de excluir",
|
||||
confirmDeleteHint: "Perguntar antes de excluir arquivos locais ou remotos.",
|
||||
startupFolder: "Pasta inicial",
|
||||
startupFolderHint: "O painel local abrirá aqui quando o Wherry iniciar.",
|
||||
homeDirectory: "Diretório inicial",
|
||||
useCurrentFolder: "Usar pasta atual",
|
||||
resetToHome: "Redefinir para inicial",
|
||||
|
||||
appearanceHint: "Escolha quais painéis ficam visíveis na janela principal.",
|
||||
toolbar: "Barra de ferramentas",
|
||||
sidebar: "Barra lateral",
|
||||
statusBar: "Barra de status",
|
||||
transferQueue: "Fila de transferências",
|
||||
|
||||
sites: "Sites salvos",
|
||||
noSites: "Ainda não há sites salvos",
|
||||
deleteSiteHover: "Excluir este site",
|
||||
removedSite: "Site «{name}» removido",
|
||||
bookmarksTitle: "Favoritos",
|
||||
noBookmarks: "Ainda não há favoritos",
|
||||
bookmarkRemoved: "Favorito removido",
|
||||
|
||||
historyTitle: "Histórico de conexões",
|
||||
historyEmpty: "Nenhum histórico de conexões ainda",
|
||||
confirmClear: "Excluir todo o histórico de conexões? Esta ação não pode ser desfeita.",
|
||||
clearAll: "Limpar todo o histórico",
|
||||
deleteAll: "Excluir tudo",
|
||||
cleared: "Histórico apagado",
|
||||
|
||||
securityHint: "As senhas são armazenadas no banco de dados local do Wherry.",
|
||||
noConnections: "Nenhuma conexão salva",
|
||||
forgetPasswordHover: "Esquecer senha salva",
|
||||
passwordForgotten: "Senha esquecida",
|
||||
forgetAll: "Esquecer todas as senhas salvas",
|
||||
allForgotten: "Todas as senhas salvas esquecidas",
|
||||
|
||||
concurrentTitle: "Transferências simultâneas",
|
||||
concurrentHint: "Quantos envios/downloads podem ser executados ao mesmo tempo.",
|
||||
autoRemoveTitle: "Remover automaticamente transferências concluídas",
|
||||
autoRemoveHint: "Remove automaticamente as transferências concluídas da fila.",
|
||||
never: "Nunca",
|
||||
tenSec: "10s",
|
||||
thirtySec: "30s",
|
||||
oneMin: "1 min",
|
||||
|
||||
version: "Versão",
|
||||
license: "Licença",
|
||||
database: "Banco de dados",
|
||||
revealInFolder: "Mostrar na pasta",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/ru.js
Normal file
175
frontend/js/i18n/ru.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Русский — переводы. Недостающие ключи автоматически берутся из en.js.
|
||||
export default {
|
||||
common: {
|
||||
open: "Открыть",
|
||||
rename: "Переименовать",
|
||||
delete: "Удалить",
|
||||
refresh: "Обновить",
|
||||
settings: "Настройки",
|
||||
history: "История",
|
||||
edit: "Изменить",
|
||||
save: "Сохранить",
|
||||
cancel: "Отмена",
|
||||
ok: "ОК",
|
||||
copyPath: "Скопировать путь",
|
||||
newFolder: "Новая папка",
|
||||
upload: "Загрузить",
|
||||
download: "Скачать",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Новое подключение",
|
||||
queueLabel: "Очередь",
|
||||
uploadQueued: "Загрузка добавлена в очередь: {name}",
|
||||
downloadQueued: "Скачивание добавлено в очередь: {name}",
|
||||
uploadQueuedMulti: "Добавлено в очередь: {n} загрузок",
|
||||
downloadQueuedMulti: "Добавлено в очередь: {n} скачиваний",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP-клиент",
|
||||
newConnection: "Новое подключение",
|
||||
recentConnections: "Недавние подключения",
|
||||
savedSites: "Сохранённые сайты",
|
||||
},
|
||||
connect: {
|
||||
title: "Новое подключение",
|
||||
host: "Хост",
|
||||
username: "Имя пользователя",
|
||||
port: "Порт",
|
||||
password: "Пароль",
|
||||
keyHint: "Оставьте пустым для входа по SSH-ключу (агент или ~/.ssh).",
|
||||
keyFile: "Файл ключа (необязательно)",
|
||||
browse: "Обзор…",
|
||||
label: "Метка (необязательно)",
|
||||
connect: "Подключиться",
|
||||
connecting: "Подключение…",
|
||||
errorHost: "Укажите хост.",
|
||||
errorUser: "Укажите имя пользователя.",
|
||||
errorPort: "Порт должен быть числом от 1 до 65535.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Недавние подключения",
|
||||
noHistory: "История подключений пуста",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "ЛОКАЛЬНО",
|
||||
remoteLabel: "УДАЛЁННО — {host}",
|
||||
loading: "Загрузка…",
|
||||
countItems: "{n} объектов",
|
||||
colName: "Имя",
|
||||
colModified: "Изменён",
|
||||
colType: "Тип",
|
||||
colSize: "Размер",
|
||||
typeDir: "Папка",
|
||||
typeFile: "Файл",
|
||||
typeLink: "Ссылка",
|
||||
pathCopied: "Путь скопирован",
|
||||
sidebarDrives: "ДИСКИ",
|
||||
sidebarBookmarks: "ЗАКЛАДКИ",
|
||||
sidebarRoot: "Корень",
|
||||
sidebarRemoveBookmark: "Удалить закладку",
|
||||
bookmarkThisFolder: "Добавить папку в закладки",
|
||||
addToBookmarks: "Добавить в закладки",
|
||||
alreadyBookmarked: "Уже в закладках",
|
||||
bookmarkAdded: "Закладка добавлена",
|
||||
uploadToServer: "Загрузить на сервер",
|
||||
downloadToLocal: "Скачать на локальный",
|
||||
notConnected: "Не подключено",
|
||||
movingFile: "Перемещение {name}…",
|
||||
movingFiles: "Перемещение {n} элементов…",
|
||||
moved: "Перемещено {n} элементов",
|
||||
moveFailedCount: "Не удалось переместить {n} элементов",
|
||||
deleted: "Удалено {n} элементов",
|
||||
deleteFailedCount: "Не удалось удалить {n} элементов",
|
||||
cannotMoveCrossConnection: "Нельзя перемещать между разными подключениями",
|
||||
noActiveConnection: "Нет активного подключения",
|
||||
opening: "Открывается {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "Передач: {n}",
|
||||
doneSuffix: "{done}/{total} готово",
|
||||
failedSuffix: "Ошибок: {n}",
|
||||
empty: "Передач пока нет",
|
||||
stateQueued: "В очереди",
|
||||
stateUploading: "Загрузка…",
|
||||
stateDownloading: "Скачивание…",
|
||||
stateComplete: "Готово",
|
||||
stateFailed: "Ошибка",
|
||||
statePaused: "Приостановлено",
|
||||
stateCancelled: "Отменено",
|
||||
stateRetrying: "Повтор…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Не подключено",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Новая папка",
|
||||
newFolderPlaceholder: "Имя папки",
|
||||
renameTitle: "Переименовать",
|
||||
renamePlaceholder: "Новое имя",
|
||||
deleteTitle: "Удалить «{name}»?",
|
||||
deleteTitleMulti: "Удалить {n} элементов?",
|
||||
deleteBody: "Это действие необратимо.",
|
||||
},
|
||||
settings: {
|
||||
title: "Настройки",
|
||||
navGeneral: "Общие",
|
||||
navAppearance: "Внешний вид",
|
||||
navConnections: "Подключения",
|
||||
navHistory: "История",
|
||||
navSecurity: "Безопасность",
|
||||
navTransfers: "Передачи",
|
||||
navAbout: "О программе",
|
||||
|
||||
language: "Язык интерфейса",
|
||||
languageHint: "Язык интерфейса.",
|
||||
confirmDelete: "Подтверждать перед удалением",
|
||||
confirmDeleteHint: "Спрашивать подтверждение перед удалением файлов локально или на сервере.",
|
||||
startupFolder: "Стартовая папка",
|
||||
startupFolderHint: "Локальная панель будет открываться здесь при запуске Wherry.",
|
||||
homeDirectory: "Домашняя папка",
|
||||
useCurrentFolder: "Использовать текущую папку",
|
||||
resetToHome: "Сбросить на домашнюю",
|
||||
|
||||
appearanceHint: "Выберите, какие панели отображать в главном окне.",
|
||||
toolbar: "Панель инструментов",
|
||||
sidebar: "Боковая панель",
|
||||
statusBar: "Строка состояния",
|
||||
transferQueue: "Очередь передач",
|
||||
|
||||
sites: "Сохранённые сайты",
|
||||
noSites: "Сохранённых сайтов пока нет",
|
||||
deleteSiteHover: "Удалить этот сайт",
|
||||
removedSite: "Сайт «{name}» удалён",
|
||||
bookmarksTitle: "Закладки",
|
||||
noBookmarks: "Закладок пока нет",
|
||||
bookmarkRemoved: "Закладка удалена",
|
||||
|
||||
historyTitle: "История подключений",
|
||||
historyEmpty: "История подключений пуста",
|
||||
confirmClear: "Удалить всю историю подключений? Это действие необратимо.",
|
||||
clearAll: "Очистить всю историю",
|
||||
deleteAll: "Удалить всё",
|
||||
cleared: "История очищена",
|
||||
|
||||
securityHint: "Пароли хранятся в локальной базе данных Wherry.",
|
||||
noConnections: "Нет сохранённых подключений",
|
||||
forgetPasswordHover: "Забыть сохранённый пароль",
|
||||
passwordForgotten: "Пароль забыт",
|
||||
forgetAll: "Забыть все сохранённые пароли",
|
||||
allForgotten: "Все сохранённые пароли забыты",
|
||||
|
||||
concurrentTitle: "Одновременные передачи",
|
||||
concurrentHint: "Сколько загрузок/скачиваний может выполняться одновременно.",
|
||||
autoRemoveTitle: "Автоудаление завершённых передач",
|
||||
autoRemoveHint: "Автоматически очищать завершённые передачи из очереди.",
|
||||
never: "Никогда",
|
||||
tenSec: "10с",
|
||||
thirtySec: "30с",
|
||||
oneMin: "1 мин",
|
||||
|
||||
version: "Версия",
|
||||
license: "Лицензия",
|
||||
database: "База данных",
|
||||
revealInFolder: "Показать в папке",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/tr.js
Normal file
175
frontend/js/i18n/tr.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Türkçe — çeviriler. Eksik anahtarlar otomatik olarak en.js'den alınır.
|
||||
export default {
|
||||
common: {
|
||||
open: "Aç",
|
||||
rename: "Yeniden Adlandır",
|
||||
delete: "Sil",
|
||||
refresh: "Yenile",
|
||||
settings: "Ayarlar",
|
||||
history: "Geçmiş",
|
||||
edit: "Düzenle",
|
||||
save: "Kaydet",
|
||||
cancel: "İptal",
|
||||
ok: "Tamam",
|
||||
copyPath: "Yolu Kopyala",
|
||||
newFolder: "Yeni Klasör",
|
||||
upload: "Yükle",
|
||||
download: "İndir",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "Yeni Bağlantı",
|
||||
queueLabel: "Kuyruk",
|
||||
uploadQueued: "Yükleme kuyruğa eklendi: {name}",
|
||||
downloadQueued: "İndirme kuyruğa eklendi: {name}",
|
||||
uploadQueuedMulti: "{n} yükleme kuyruğa eklendi",
|
||||
downloadQueuedMulti: "{n} indirme kuyruğa eklendi",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP İstemcisi",
|
||||
newConnection: "Yeni Bağlantı",
|
||||
recentConnections: "Son Bağlantılar",
|
||||
savedSites: "Kayıtlı Siteler",
|
||||
},
|
||||
connect: {
|
||||
title: "Yeni Bağlantı",
|
||||
host: "Sunucu",
|
||||
username: "Kullanıcı Adı",
|
||||
port: "Port",
|
||||
password: "Parola",
|
||||
keyHint: "SSH anahtarıyla oturum açmak için boş bırakın (aracı veya ~/.ssh).",
|
||||
keyFile: "Anahtar dosyası (isteğe bağlı)",
|
||||
browse: "Gözat…",
|
||||
label: "Etiket (isteğe bağlı)",
|
||||
connect: "Bağlan",
|
||||
connecting: "Bağlanıyor…",
|
||||
errorHost: "Sunucu adresi gerekli.",
|
||||
errorUser: "Kullanıcı adı gerekli.",
|
||||
errorPort: "Port 1 ile 65535 arasında bir sayı olmalıdır.",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "Son Bağlantılar",
|
||||
noHistory: "Henüz bağlantı geçmişi yok",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "YEREL",
|
||||
remoteLabel: "UZAK — {host}",
|
||||
loading: "Yükleniyor…",
|
||||
countItems: "{n} öğe",
|
||||
colName: "Ad",
|
||||
colModified: "Değiştirilme",
|
||||
colType: "Tür",
|
||||
colSize: "Boyut",
|
||||
typeDir: "Klasör",
|
||||
typeFile: "Dosya",
|
||||
typeLink: "Bağlantı",
|
||||
pathCopied: "Yol kopyalandı",
|
||||
sidebarDrives: "SÜRÜCÜLER",
|
||||
sidebarBookmarks: "YER İMLERİ",
|
||||
sidebarRoot: "Kök",
|
||||
sidebarRemoveBookmark: "Yer imini kaldır",
|
||||
bookmarkThisFolder: "Bu klasörü yer imlerine ekle",
|
||||
addToBookmarks: "Yer imlerine ekle",
|
||||
alreadyBookmarked: "Zaten yer imlerinde",
|
||||
bookmarkAdded: "Yer imi eklendi",
|
||||
uploadToServer: "Sunucuya Yükle",
|
||||
downloadToLocal: "Yerel İndir",
|
||||
notConnected: "Bağlı değil",
|
||||
movingFile: "{name} taşınıyor…",
|
||||
movingFiles: "{n} öğe taşınıyor…",
|
||||
moved: "{n} öğe taşındı",
|
||||
moveFailedCount: "{n} öğe taşınamadı",
|
||||
deleted: "{n} öğe silindi",
|
||||
deleteFailedCount: "{n} öğe silinemedi",
|
||||
cannotMoveCrossConnection: "Farklı bağlantılar arasında taşıma yapılamaz",
|
||||
noActiveConnection: "Etkin bağlantı yok",
|
||||
opening: "{name} açılıyor…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} aktarım",
|
||||
doneSuffix: "{done}/{total} tamamlandı",
|
||||
failedSuffix: "{n} başarısız",
|
||||
empty: "Henüz aktarım yok",
|
||||
stateQueued: "Sırada",
|
||||
stateUploading: "Yükleniyor…",
|
||||
stateDownloading: "İndiriliyor…",
|
||||
stateComplete: "Tamamlandı",
|
||||
stateFailed: "Başarısız",
|
||||
statePaused: "Duraklatıldı",
|
||||
stateCancelled: "İptal edildi",
|
||||
stateRetrying: "Yeniden deneniyor…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "Bağlı değil",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "Yeni Klasör",
|
||||
newFolderPlaceholder: "Klasör adı",
|
||||
renameTitle: "Yeniden Adlandır",
|
||||
renamePlaceholder: "Yeni ad",
|
||||
deleteTitle: "\"{name}\" silinsin mi?",
|
||||
deleteTitleMulti: "{n} öğe silinsin mi?",
|
||||
deleteBody: "Bu işlem geri alınamaz.",
|
||||
},
|
||||
settings: {
|
||||
title: "Ayarlar",
|
||||
navGeneral: "Genel",
|
||||
navAppearance: "Görünüm",
|
||||
navConnections: "Bağlantılar",
|
||||
navHistory: "Geçmiş",
|
||||
navSecurity: "Güvenlik",
|
||||
navTransfers: "Aktarımlar",
|
||||
navAbout: "Hakkında",
|
||||
|
||||
language: "Dil",
|
||||
languageHint: "Arayüz dili.",
|
||||
confirmDelete: "Silmeden önce onayla",
|
||||
confirmDeleteHint: "Yerel veya uzak dosyaları silmeden önce bir onay penceresi gösterir.",
|
||||
startupFolder: "Başlangıç klasörü",
|
||||
startupFolderHint: "Wherry başlatıldığında yerel panel burada açılır.",
|
||||
homeDirectory: "Ana dizin",
|
||||
useCurrentFolder: "Geçerli Klasörü Kullan",
|
||||
resetToHome: "Ana Dizine Sıfırla",
|
||||
|
||||
appearanceHint: "Ana penceredeki hangi panellerin görüneceğini seçin.",
|
||||
toolbar: "Araç Çubuğu",
|
||||
sidebar: "Kenar Çubuğu",
|
||||
statusBar: "Durum Çubuğu",
|
||||
transferQueue: "Aktarım Kuyruğu",
|
||||
|
||||
sites: "Kayıtlı Siteler",
|
||||
noSites: "Henüz kayıtlı site yok",
|
||||
deleteSiteHover: "Bu siteyi sil",
|
||||
removedSite: "\"{name}\" sitesi kaldırıldı",
|
||||
bookmarksTitle: "Yer İmleri",
|
||||
noBookmarks: "Henüz yer imi yok",
|
||||
bookmarkRemoved: "Yer imi kaldırıldı",
|
||||
|
||||
historyTitle: "Bağlantı Geçmişi",
|
||||
historyEmpty: "Henüz bağlantı geçmişi yok",
|
||||
confirmClear: "Tüm bağlantı geçmişi silinsin mi? Bu işlem geri alınamaz.",
|
||||
clearAll: "Tüm Geçmişi Temizle",
|
||||
deleteAll: "Tümünü Sil",
|
||||
cleared: "Geçmiş temizlendi",
|
||||
|
||||
securityHint: "Parolalar, Wherry'nin yerel veritabanında saklanır.",
|
||||
noConnections: "Kayıtlı bağlantı yok",
|
||||
forgetPasswordHover: "Kayıtlı parolayı unut",
|
||||
passwordForgotten: "Parola unutuldu",
|
||||
forgetAll: "Kayıtlı Tüm Parolaları Unut",
|
||||
allForgotten: "Kayıtlı tüm parolalar unutuldu",
|
||||
|
||||
concurrentTitle: "Eşzamanlı Aktarımlar",
|
||||
concurrentHint: "Aynı anda kaç yükleme/indirme çalışabileceği.",
|
||||
autoRemoveTitle: "Tamamlanan Aktarımları Otomatik Kaldır",
|
||||
autoRemoveHint: "Tamamlanan aktarımları kuyruktan otomatik olarak temizler.",
|
||||
never: "Asla",
|
||||
tenSec: "10sn",
|
||||
thirtySec: "30sn",
|
||||
oneMin: "1 dk",
|
||||
|
||||
version: "Sürüm",
|
||||
license: "Lisans",
|
||||
database: "Veritabanı",
|
||||
revealInFolder: "Klasörde Göster",
|
||||
},
|
||||
};
|
||||
175
frontend/js/i18n/zh.js
Normal file
175
frontend/js/i18n/zh.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// 简体中文 — 翻译。缺失的键会自动从 en.js 中获取。
|
||||
export default {
|
||||
common: {
|
||||
open: "打开",
|
||||
rename: "重命名",
|
||||
delete: "删除",
|
||||
refresh: "刷新",
|
||||
settings: "设置",
|
||||
history: "历史记录",
|
||||
edit: "编辑",
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
ok: "确定",
|
||||
copyPath: "复制路径",
|
||||
newFolder: "新建文件夹",
|
||||
upload: "上传",
|
||||
download: "下载",
|
||||
},
|
||||
toolbar: {
|
||||
newConnection: "新建连接",
|
||||
queueLabel: "队列",
|
||||
uploadQueued: "已加入上传队列:{name}",
|
||||
downloadQueued: "已加入下载队列:{name}",
|
||||
uploadQueuedMulti: "已加入队列: {n} 个上传",
|
||||
downloadQueuedMulti: "已加入队列: {n} 个下载",
|
||||
},
|
||||
welcome: {
|
||||
subtitle: "FTP / SFTP 客户端",
|
||||
newConnection: "新建连接",
|
||||
recentConnections: "最近的连接",
|
||||
savedSites: "已保存的站点",
|
||||
},
|
||||
connect: {
|
||||
title: "新建连接",
|
||||
host: "主机",
|
||||
username: "用户名",
|
||||
port: "端口",
|
||||
password: "密码",
|
||||
keyHint: "留空以使用 SSH 密钥登录(代理或 ~/.ssh)。",
|
||||
keyFile: "密钥文件(可选)",
|
||||
browse: "浏览…",
|
||||
label: "标签(可选)",
|
||||
connect: "连接",
|
||||
connecting: "正在连接…",
|
||||
errorHost: "请输入主机地址。",
|
||||
errorUser: "请输入用户名。",
|
||||
errorPort: "端口必须是 1 到 65535 之间的数字。",
|
||||
},
|
||||
history: {
|
||||
recentConnections: "最近的连接",
|
||||
noHistory: "暂无连接历史记录",
|
||||
},
|
||||
panels: {
|
||||
localLabel: "本地",
|
||||
remoteLabel: "远程 — {host}",
|
||||
loading: "加载中…",
|
||||
countItems: "{n} 项",
|
||||
colName: "名称",
|
||||
colModified: "修改时间",
|
||||
colType: "类型",
|
||||
colSize: "大小",
|
||||
typeDir: "文件夹",
|
||||
typeFile: "文件",
|
||||
typeLink: "链接",
|
||||
pathCopied: "路径已复制",
|
||||
sidebarDrives: "磁盘",
|
||||
sidebarBookmarks: "书签",
|
||||
sidebarRoot: "根目录",
|
||||
sidebarRemoveBookmark: "移除书签",
|
||||
bookmarkThisFolder: "将此文件夹加入书签",
|
||||
addToBookmarks: "添加到书签",
|
||||
alreadyBookmarked: "已在书签中",
|
||||
bookmarkAdded: "已添加书签",
|
||||
uploadToServer: "上传到服务器",
|
||||
downloadToLocal: "下载到本地",
|
||||
notConnected: "未连接",
|
||||
movingFile: "正在移动 {name}…",
|
||||
movingFiles: "正在移动 {n} 项…",
|
||||
moved: "已移动 {n} 项",
|
||||
moveFailedCount: "{n} 项移动失败",
|
||||
deleted: "已删除 {n} 项",
|
||||
deleteFailedCount: "{n} 项删除失败",
|
||||
cannotMoveCrossConnection: "无法在不同连接之间移动",
|
||||
noActiveConnection: "没有活动连接",
|
||||
opening: "正在打开 {name}…",
|
||||
},
|
||||
queue: {
|
||||
title: "{n} 个传输",
|
||||
doneSuffix: "{done}/{total} 已完成",
|
||||
failedSuffix: "{n} 个失败",
|
||||
empty: "暂无传输",
|
||||
stateQueued: "排队中",
|
||||
stateUploading: "上传中…",
|
||||
stateDownloading: "下载中…",
|
||||
stateComplete: "已完成",
|
||||
stateFailed: "失败",
|
||||
statePaused: "已暂停",
|
||||
stateCancelled: "已取消",
|
||||
stateRetrying: "正在重试…",
|
||||
},
|
||||
status: {
|
||||
notConnected: "未连接",
|
||||
},
|
||||
dialogs: {
|
||||
newFolderTitle: "新建文件夹",
|
||||
newFolderPlaceholder: "文件夹名称",
|
||||
renameTitle: "重命名",
|
||||
renamePlaceholder: "新名称",
|
||||
deleteTitle: "删除「{name}」?",
|
||||
deleteTitleMulti: "删除 {n} 项?",
|
||||
deleteBody: "此操作无法撤销。",
|
||||
},
|
||||
settings: {
|
||||
title: "设置",
|
||||
navGeneral: "通用",
|
||||
navAppearance: "外观",
|
||||
navConnections: "连接",
|
||||
navHistory: "历史记录",
|
||||
navSecurity: "安全",
|
||||
navTransfers: "传输",
|
||||
navAbout: "关于",
|
||||
|
||||
language: "语言",
|
||||
languageHint: "界面语言。",
|
||||
confirmDelete: "删除前进行确认",
|
||||
confirmDeleteHint: "删除本地或远程文件前显示确认对话框。",
|
||||
startupFolder: "启动文件夹",
|
||||
startupFolderHint: "Wherry 启动时,本地面板将在此处打开。",
|
||||
homeDirectory: "主目录",
|
||||
useCurrentFolder: "使用当前文件夹",
|
||||
resetToHome: "重置为主目录",
|
||||
|
||||
appearanceHint: "选择主窗口中显示哪些面板。",
|
||||
toolbar: "工具栏",
|
||||
sidebar: "侧边栏",
|
||||
statusBar: "状态栏",
|
||||
transferQueue: "传输队列",
|
||||
|
||||
sites: "已保存的站点",
|
||||
noSites: "尚无已保存的站点",
|
||||
deleteSiteHover: "删除此站点",
|
||||
removedSite: "已删除站点「{name}」",
|
||||
bookmarksTitle: "书签",
|
||||
noBookmarks: "尚无书签",
|
||||
bookmarkRemoved: "已移除书签",
|
||||
|
||||
historyTitle: "连接历史记录",
|
||||
historyEmpty: "暂无连接历史记录",
|
||||
confirmClear: "删除全部连接历史记录?此操作无法撤销。",
|
||||
clearAll: "清除全部历史记录",
|
||||
deleteAll: "全部删除",
|
||||
cleared: "历史记录已清除",
|
||||
|
||||
securityHint: "密码存储在 Wherry 的本地数据库中。",
|
||||
noConnections: "没有已保存的连接",
|
||||
forgetPasswordHover: "忘记已保存的密码",
|
||||
passwordForgotten: "密码已忘记",
|
||||
forgetAll: "忘记所有已保存的密码",
|
||||
allForgotten: "所有已保存的密码已忘记",
|
||||
|
||||
concurrentTitle: "并发传输数",
|
||||
concurrentHint: "可同时运行的上传/下载数量。",
|
||||
autoRemoveTitle: "自动移除已完成的传输",
|
||||
autoRemoveHint: "自动从队列中清除已完成的传输。",
|
||||
never: "从不",
|
||||
tenSec: "10秒",
|
||||
thirtySec: "30秒",
|
||||
oneMin: "1分钟",
|
||||
|
||||
version: "版本",
|
||||
license: "许可证",
|
||||
database: "数据库",
|
||||
revealInFolder: "在文件夹中显示",
|
||||
},
|
||||
};
|
||||
51
frontend/js/icons-data.js
Normal file
51
frontend/js/icons-data.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Auto-generated from @solar-icons/react (BoldDuotone style).
|
||||
// DO NOT EDIT -- run `node scripts/extract-icons.mjs` to regenerate.
|
||||
// Source: https://solar-icons.vercel.app
|
||||
|
||||
export const ICONS = {
|
||||
folder: `<path fill="currentColor" opacity="0.5" d="M22 14V11.7979C22 9.16554 22 7.84935 21.2305 6.99383C21.1598 6.91514 21.0849 6.84024 21.0062 6.76946C20.1506 6 18.8345 6 16.2021 6H15.8284C14.6747 6 14.0979 6 13.5604 5.84678C13.2651 5.7626 12.9804 5.64471 12.7121 5.49543C12.2237 5.22367 11.8158 4.81578 11 4L10.4497 3.44975C10.1763 3.17633 10.0396 3.03961 9.89594 2.92051C9.27652 2.40704 8.51665 2.09229 7.71557 2.01738C7.52976 2 7.33642 2 6.94975 2C6.06722 2 5.62595 2 5.25839 2.06935C3.64031 2.37464 2.37464 3.64031 2.06935 5.25839C2 5.62595 2 6.06722 2 6.94975V14C2 17.7712 2 19.6569 3.17157 20.8284C4.34315 22 6.22876 22 10 22H14C17.7712 22 19.6569 22 20.8284 20.8284C22 19.6569 22 17.7712 22 14Z"/><path fill="currentColor" d="M12.25 10C12.25 9.58579 12.5858 9.25 13 9.25H18C18.4142 9.25 18.75 9.58579 18.75 10C18.75 10.4142 18.4142 10.75 18 10.75H13C12.5858 10.75 12.25 10.4142 12.25 10Z"/>`,
|
||||
folderAdd: `<path fill="currentColor" opacity="0.5" d="M22 13.75V11.5479C22 8.91554 22 7.59935 21.2305 6.74383C21.1598 6.66514 21.0849 6.59024 21.0062 6.51946C20.1506 5.75 18.8345 5.75 16.2021 5.75H15.8284C14.6747 5.75 14.0979 5.75 13.5604 5.59678C13.2651 5.5126 12.9804 5.39471 12.7121 5.24543C12.2237 4.97367 11.8158 4.56578 11 3.75L10.4497 3.19975C10.1763 2.92633 10.0396 2.78961 9.89594 2.67051C9.27652 2.15704 8.51665 1.84229 7.71557 1.76738C7.52976 1.75 7.33642 1.75 6.94975 1.75C6.06722 1.75 5.62595 1.75 5.25839 1.81935C3.64031 2.12464 2.37464 3.39031 2.06935 5.00839C2 5.37595 2 5.81722 2 6.69975V13.75C2 17.5212 2 19.4069 3.17157 20.5784C4.34315 21.75 6.22876 21.75 10 21.75H14C17.7712 21.75 19.6569 21.75 20.8284 20.5784C22 19.4069 22 17.5212 22 13.75Z"/><path fill="currentColor" d="M12 11C12.4142 11 12.75 11.3358 12.75 11.75V13H14C14.4142 13 14.75 13.3358 14.75 13.75C14.75 14.1642 14.4142 14.5 14 14.5H12.75V15.75C12.75 16.1642 12.4142 16.5 12 16.5C11.5858 16.5 11.25 16.1642 11.25 15.75V14.5H10C9.58579 14.5 9.25 14.1642 9.25 13.75C9.25 13.3358 9.58579 13 10 13H11.25V11.75C11.25 11.3358 11.5858 11 12 11Z"/>`,
|
||||
copy: `<path fill="currentColor" d="M6.59961 11.3974C6.59961 8.67119 6.59961 7.3081 7.44314 6.46118C8.28667 5.61426 9.64432 5.61426 12.3596 5.61426H15.2396C17.9549 5.61426 19.3125 5.61426 20.1561 6.46118C20.9996 7.3081 20.9996 8.6712 20.9996 11.3974V16.2167C20.9996 18.9429 20.9996 20.306 20.1561 21.1529C19.3125 21.9998 17.9549 21.9998 15.2396 21.9998H12.3596C9.64432 21.9998 8.28667 21.9998 7.44314 21.1529C6.59961 20.306 6.59961 18.9429 6.59961 16.2167V11.3974Z"/><path fill="currentColor" opacity="0.5" d="M4.17157 3.17157C3 4.34315 3 6.22876 3 10V12C3 15.7712 3 17.6569 4.17157 18.8284C4.78913 19.446 5.6051 19.738 6.79105 19.8761C6.59961 19.0353 6.59961 17.8796 6.59961 16.2167V11.3974C6.59961 8.6712 6.59961 7.3081 7.44314 6.46118C8.28667 5.61426 9.64432 5.61426 12.3596 5.61426H15.2396C16.8915 5.61426 18.0409 5.61426 18.8777 5.80494C18.7403 4.61146 18.4484 3.79154 17.8284 3.17157C16.6569 2 14.7712 2 11 2C7.22876 2 5.34315 2 4.17157 3.17157Z"/>`,
|
||||
addCircle: `<path fill="currentColor" d="M12.75 9C12.75 8.58579 12.4142 8.25 12 8.25C11.5858 8.25 11.25 8.58579 11.25 9L11.25 11.25H9C8.58579 11.25 8.25 11.5858 8.25 12C8.25 12.4142 8.58579 12.75 9 12.75H11.25V15C11.25 15.4142 11.5858 15.75 12 15.75C12.4142 15.75 12.75 15.4142 12.75 15L12.75 12.75H15C15.4142 12.75 15.75 12.4142 15.75 12C15.75 11.5858 15.4142 11.25 15 11.25H12.75V9Z"/>`,
|
||||
serverSquare: `<path fill="currentColor" d="M14 21H10C6.22876 21 4.34315 21 3.17157 19.8284C2 18.6569 2 16.7712 2 13V12.75H22V13C22 16.7712 22 18.6569 20.8284 19.8284C19.6569 21 17.7712 21 14 21Z"/><path fill="currentColor" d="M10 3H14C17.7712 3 19.6569 3 20.8284 4.17157C22 5.34315 22 7.22876 22 11V11.25H2V11C2 7.22876 2 5.34315 3.17157 4.17157C4.34315 3 6.22876 3 10 3Z"/><path fill="currentColor" d="M22 12.75H2V11.25H22V12.75Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M12.75 16.5C12.75 16.0858 13.0858 15.75 13.5 15.75H18C18.4142 15.75 18.75 16.0858 18.75 16.5C18.75 16.9142 18.4142 17.25 18 17.25H13.5C13.0858 17.25 12.75 16.9142 12.75 16.5Z"/><path fill="currentColor" d="M12.75 7.5C12.75 7.08579 13.0858 6.75 13.5 6.75H18C18.4142 6.75 18.75 7.08579 18.75 7.5C18.75 7.91421 18.4142 8.25 18 8.25H13.5C13.0858 8.25 12.75 7.91421 12.75 7.5Z"/><path fill="currentColor" d="M6 18.25C5.58579 18.25 5.25 17.9142 5.25 17.5V15.5C5.25 15.0858 5.58579 14.75 6 14.75C6.41421 14.75 6.75 15.0858 6.75 15.5V17.5C6.75 17.9142 6.41421 18.25 6 18.25Z"/><path fill="currentColor" d="M6 9.25C5.58579 9.25 5.25 8.91421 5.25 8.5V6.5C5.25 6.08579 5.58579 5.75 6 5.75C6.41421 5.75 6.75 6.08579 6.75 6.5V8.5C6.75 8.91421 6.41421 9.25 6 9.25Z"/><path fill="currentColor" d="M9 18.25C8.58579 18.25 8.25 17.9142 8.25 17.5V15.5C8.25 15.0858 8.58579 14.75 9 14.75C9.41421 14.75 9.75 15.0858 9.75 15.5V17.5C9.75 17.9142 9.41421 18.25 9 18.25Z"/><path fill="currentColor" d="M9 9.25C8.58579 9.25 8.25 8.91421 8.25 8.5V6.5C8.25 6.08579 8.58579 5.75 9 5.75C9.41421 5.75 9.75 6.08579 9.75 6.5V8.5C9.75 8.91421 9.41421 9.25 9 9.25Z"/>`,
|
||||
dangerTriangle: `<path fill="currentColor" opacity="0.5" d="M12 3C9.68925 3 8.23007 5.58716 5.31171 10.7615L4.94805 11.4063C2.52291 15.7061 1.31034 17.856 2.40626 19.428C3.50217 21 6.21356 21 11.6363 21H12.3637C17.7864 21 20.4978 21 21.5937 19.428C22.6897 17.856 21.4771 15.7061 19.0519 11.4063L18.6883 10.7615C15.7699 5.58716 14.3107 3 12 3Z"/><path fill="currentColor" d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V13C12.75 13.4142 12.4142 13.75 12 13.75C11.5858 13.75 11.25 13.4142 11.25 13V8C11.25 7.58579 11.5858 7.25 12 7.25Z"/><path fill="currentColor" d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z"/>`,
|
||||
upload: `<path fill="currentColor" opacity="0.5" d="M22 15.9998V14.9998C22 12.1714 21.9998 10.7576 21.1211 9.87891C20.2424 9.00023 18.8282 9.00023 15.9998 9.00023H7.99977C5.17135 9.00023 3.75713 9.00023 2.87845 9.87891C2 10.7574 2 12.1706 2 14.9976V14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998Z"/><path fill="currentColor" d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15L12.75 4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5695 5.01191L12.5695 1.51191C12.427 1.34567 12.219 1.25 12 1.25C11.7811 1.25 11.5731 1.34567 11.4306 1.51191L8.43057 5.01191C8.161 5.3264 8.19743 5.79988 8.51192 6.06944C8.82641 6.33901 9.29989 6.30259 9.56946 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
download: `<path fill="currentColor" opacity="0.5" d="M22 16.0003V15.0003C22 12.1718 21.9998 10.7581 21.1211 9.8794C20.2424 9.00072 18.8282 9.00072 15.9998 9.00072H7.99977C5.17135 9.00072 3.75713 9.00072 2.87845 9.8794C2 10.7579 2 12.1711 2 14.9981V15.0003V16.0003C2 18.8287 2 20.2429 2.87868 21.1216C3.75736 22.0003 5.17157 22.0003 8 22.0003H16H16C18.8284 22.0003 20.2426 22.0003 21.1213 21.1216C22 20.2429 22 18.8287 22 16.0003Z"/><path fill="currentColor" d="M12 1.25C11.5858 1.25 11.25 1.58579 11.25 2L11.25 12.9726L9.56943 11.0119C9.29986 10.6974 8.82639 10.661 8.51189 10.9306C8.1974 11.2001 8.16098 11.6736 8.43054 11.9881L11.4305 15.4881C11.573 15.6543 11.781 15.75 12 15.75C12.2189 15.75 12.4269 15.6543 12.5694 15.4881L15.5694 11.9881C15.839 11.6736 15.8026 11.2001 15.4881 10.9306C15.1736 10.661 14.7001 10.6974 14.4305 11.0119L12.75 12.9726L12.75 2C12.75 1.58579 12.4142 1.25 12 1.25Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
downloadSquare: `<path fill="currentColor" opacity="0.5" d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z"/><path fill="currentColor" d="M12.75 7C12.75 6.58579 12.4142 6.25 12 6.25C11.5858 6.25 11.25 6.58579 11.25 7L11.25 12.1893L9.53033 10.4697C9.23744 10.1768 8.76256 10.1768 8.46967 10.4697C8.17678 10.7626 8.17678 11.2374 8.46967 11.5303L11.4697 14.5303C11.6103 14.671 11.8011 14.75 12 14.75C12.1989 14.75 12.3897 14.671 12.5303 14.5303L15.5303 11.5303C15.8232 11.2374 15.8232 10.7626 15.5303 10.4697C15.2374 10.1768 14.7626 10.1768 14.4697 10.4697L12.75 12.1893V7Z"/><path fill="currentColor" d="M8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"/>`,
|
||||
clock: `<path fill="currentColor" opacity="0.5" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path fill="currentColor" d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
refresh: `<path fill="currentColor" d="M12.0789 2.25C7.2854 2.25 3.34478 5.913 2.96055 10.5833H2.00002C1.69614 10.5833 1.42229 10.7667 1.30655 11.0477C1.19081 11.3287 1.25606 11.6517 1.47178 11.8657L3.15159 13.5324C3.444 13.8225 3.91567 13.8225 4.20808 13.5324L5.88789 11.8657C6.10361 11.6517 6.16886 11.3287 6.05312 11.0477C5.93738 10.7667 5.66353 10.5833 5.35965 10.5833H4.4668C4.84652 6.75167 8.10479 3.75 12.0789 3.75C14.8484 3.75 17.2727 5.20845 18.6156 7.39279C18.8325 7.74565 19.2944 7.85585 19.6473 7.63892C20.0002 7.42199 20.1104 6.96007 19.8934 6.60721C18.2871 3.99427 15.3873 2.25 12.0789 2.25Z"/><path fill="currentColor" opacity="0.5" d="M20.8412 10.4666C20.5491 10.1778 20.0789 10.1778 19.7868 10.4666L18.1005 12.1333C17.8842 12.3471 17.8185 12.6703 17.934 12.9517C18.0496 13.233 18.3236 13.4167 18.6278 13.4167H19.5269C19.1456 17.2462 15.876 20.25 11.8828 20.25C9.10034 20.25 6.66595 18.7903 5.31804 16.6061C5.10051 16.2536 4.63841 16.1442 4.28591 16.3618C3.93342 16.5793 3.82401 17.0414 4.04154 17.3939C5.65416 20.007 8.56414 21.75 11.8828 21.75C16.6907 21.75 20.6476 18.0892 21.0332 13.4167H22.0002C22.3044 13.4167 22.5784 13.233 22.694 12.9517C22.8096 12.6703 22.7438 12.3471 22.5275 12.1333L20.8412 10.4666Z"/>`,
|
||||
trash: `<path fill="currentColor" d="M3 6.38597C3 5.90152 3.34538 5.50879 3.77143 5.50879L6.43567 5.50832C6.96502 5.49306 7.43202 5.11033 7.61214 4.54412C7.61688 4.52923 7.62232 4.51087 7.64185 4.44424L7.75665 4.05256C7.8269 3.81241 7.8881 3.60318 7.97375 3.41617C8.31209 2.67736 8.93808 2.16432 9.66147 2.03297C9.84457 1.99972 10.0385 1.99986 10.2611 2.00002H13.7391C13.9617 1.99986 14.1556 1.99972 14.3387 2.03297C15.0621 2.16432 15.6881 2.67736 16.0264 3.41617C16.1121 3.60318 16.1733 3.81241 16.2435 4.05256L16.3583 4.44424C16.3778 4.51087 16.3833 4.52923 16.388 4.54412C16.5682 5.11033 17.1278 5.49353 17.6571 5.50879H20.2286C20.6546 5.50879 21 5.90152 21 6.38597C21 6.87043 20.6546 7.26316 20.2286 7.26316H3.77143C3.34538 7.26316 3 6.87043 3 6.38597Z"/><path fill="currentColor" d="M9.42543 11.4815C9.83759 11.4381 10.2051 11.7547 10.2463 12.1885L10.7463 17.4517C10.7875 17.8855 10.4868 18.2724 10.0747 18.3158C9.66253 18.3592 9.29499 18.0426 9.25378 17.6088L8.75378 12.3456C8.71256 11.9118 9.01327 11.5249 9.42543 11.4815Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M14.5747 11.4815C14.9868 11.5249 15.2875 11.9118 15.2463 12.3456L14.7463 17.6088C14.7051 18.0426 14.3376 18.3592 13.9254 18.3158C13.5133 18.2724 13.2126 17.8855 13.2538 17.4517L13.7538 12.1885C13.795 11.7547 14.1625 11.4381 14.5747 11.4815Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" opacity="0.5" d="M11.5956 22.0001H12.4044C15.1871 22.0001 16.5785 22.0001 17.4831 21.1142C18.3878 20.2283 18.4803 18.7751 18.6654 15.8686L18.9321 11.6807C19.0326 10.1037 19.0828 9.31524 18.6289 8.81558C18.1751 8.31592 17.4087 8.31592 15.876 8.31592H8.12405C6.59127 8.31592 5.82488 8.31592 5.37105 8.81558C4.91722 9.31524 4.96744 10.1037 5.06788 11.6807L5.33459 15.8686C5.5197 18.7751 5.61225 20.2283 6.51689 21.1142C7.42153 22.0001 8.81289 22.0001 11.5956 22.0001Z"/>`,
|
||||
pen: `<path fill="currentColor" opacity="0.5" d="M20.8487 8.71306C22.3844 7.17735 22.3844 4.68748 20.8487 3.15178C19.313 1.61607 16.8231 1.61607 15.2874 3.15178L14.4004 4.03882C14.4125 4.0755 14.4251 4.11268 14.4382 4.15035C14.7633 5.0875 15.3768 6.31601 16.5308 7.47002C17.6848 8.62403 18.9133 9.23749 19.8505 9.56262C19.888 9.57563 19.925 9.58817 19.9615 9.60026L20.8487 8.71306Z"/><path fill="currentColor" d="M14.4386 4L14.4004 4.03819C14.4125 4.07487 14.4251 4.11206 14.4382 4.14973C14.7633 5.08687 15.3768 6.31538 16.5308 7.4694C17.6848 8.62341 18.9133 9.23686 19.8505 9.56199C19.8876 9.57489 19.9243 9.58733 19.9606 9.59933L11.4001 18.1598C10.823 18.7369 10.5343 19.0255 10.2162 19.2737C9.84082 19.5665 9.43469 19.8175 9.00498 20.0223C8.6407 20.1959 8.25351 20.3249 7.47918 20.583L3.39584 21.9442C3.01478 22.0712 2.59466 21.972 2.31063 21.688C2.0266 21.4039 1.92743 20.9838 2.05445 20.6028L3.41556 16.5194C3.67368 15.7451 3.80273 15.3579 3.97634 14.9936C4.18114 14.5639 4.43213 14.1578 4.7249 13.7824C4.97307 13.4643 5.26165 13.1757 5.83874 12.5986L14.4386 4Z"/>`,
|
||||
history: `<path fill="currentColor" d="M5.07868 5.06891C8.87402 1.27893 15.0437 1.31923 18.8622 5.13778C22.6824 8.95797 22.7211 15.1313 18.9262 18.9262C15.1312 22.7211 8.95793 22.6824 5.13774 18.8622C2.87389 16.5984 1.93904 13.5099 2.34047 10.5812C2.39672 10.1708 2.775 9.88377 3.18537 9.94002C3.59575 9.99627 3.88282 10.3745 3.82658 10.7849C3.4866 13.2652 4.27782 15.881 6.1984 17.8016C9.44288 21.0461 14.6664 21.0646 17.8655 17.8655C21.0646 14.6664 21.046 9.44292 17.8015 6.19844C14.5587 2.95561 9.33889 2.93539 6.13935 6.12957L6.88705 6.13333C7.30126 6.13541 7.63535 6.47288 7.63327 6.88709C7.63119 7.3013 7.29372 7.63539 6.87951 7.63331L4.33396 7.62052C3.92269 7.61845 3.58981 7.28556 3.58774 6.8743L3.57495 4.32874C3.57286 3.91454 3.90696 3.57707 4.32117 3.57498C4.73538 3.5729 5.07285 3.907 5.07493 4.32121L5.07868 5.06891Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" opacity="0.5" d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.5429 12.6036C11.3554 12.416 11.25 12.1617 11.25 11.8964V8C11.25 7.58579 11.5858 7.25 12 7.25Z"/>`,
|
||||
settings: `<path fill="currentColor" opacity="0.5" d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M15.5227 12C15.5227 13.6569 14.1694 15 12.4999 15C10.8304 15 9.47705 13.6569 9.47705 12C9.47705 10.3431 10.8304 9 12.4999 9C14.1694 9 15.5227 10.3431 15.5227 12Z"/>`,
|
||||
home: `<path fill="currentColor" opacity="0.5" d="M2 12.2039C2 9.91549 2 8.77128 2.5192 7.82274C3.0384 6.87421 3.98695 6.28551 5.88403 5.10813L7.88403 3.86687C9.88939 2.62229 10.8921 2 12 2C13.1079 2 14.1106 2.62229 16.116 3.86687L18.116 5.10812C20.0131 6.28551 20.9616 6.87421 21.4808 7.82274C22 8.77128 22 9.91549 22 12.2039V13.725C22 17.6258 22 19.5763 20.8284 20.7881C19.6569 22 17.7712 22 14 22H10C6.22876 22 4.34315 22 3.17157 20.7881C2 19.5763 2 17.6258 2 13.725V12.2039Z"/><path fill="currentColor" d="M9 17.25C8.58579 17.25 8.25 17.5858 8.25 18C8.25 18.4142 8.58579 18.75 9 18.75H15C15.4142 18.75 15.75 18.4142 15.75 18C15.75 17.5858 15.4142 17.25 15 17.25H9Z"/>`,
|
||||
monitor: `<path fill="currentColor" opacity="0.5" d="M10 2H14C17.7712 2 19.6569 2 20.8284 3.17157C22 4.34315 22 6.22876 22 10V11C22 11.5516 22 12.5494 21.9935 13H2.00652C2 12.5494 2 11.5516 2 11V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.22876 2 10 2Z"/><path fill="currentColor" d="M7.9846 17.5C5.14528 17.5 3.72562 17.5 2.84356 16.6213C2.27207 16.052 2.07085 15.2579 2 14V13H22V14C21.9292 15.2579 21.7279 16.052 21.1564 16.6213C20.2744 17.5 18.8547 17.5 16.0154 17.5H12.7529V21.5H16.0154C16.4312 21.5 16.7683 21.8358 16.7683 22.25C16.7683 22.6642 16.4312 23 16.0154 23H7.9846C7.56879 23 7.23171 22.6642 7.23171 22.25C7.23171 21.8358 7.56879 21.5 7.9846 21.5H11.2471V17.5H7.9846Z"/>`,
|
||||
document: `<path fill="currentColor" opacity="0.5" d="M3 10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H13C16.7712 2 18.6569 2 19.8284 3.17157C21 4.34315 21 6.22876 21 10V14C21 17.7712 21 19.6569 19.8284 20.8284C18.6569 22 16.7712 22 13 22H11C7.22876 22 5.34315 22 4.17157 20.8284C3 19.6569 3 17.7712 3 14V10Z"/><path fill="currentColor" d="M7.25 10C7.25 9.58579 7.58579 9.25 8 9.25H16C16.4142 9.25 16.75 9.58579 16.75 10C16.75 10.4142 16.4142 10.75 16 10.75H8C7.58579 10.75 7.25 10.4142 7.25 10Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M7.25 14C7.25 13.5858 7.58579 13.25 8 13.25H13C13.4142 13.25 13.75 13.5858 13.75 14C13.75 14.4142 13.4142 14.75 13 14.75H8C7.58579 14.75 7.25 14.4142 7.25 14Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
documentText: `<path fill="currentColor" opacity="0.5" d="M3 10C3 6.22876 3 4.34315 4.17157 3.17157C5.34315 2 7.22876 2 11 2H13C16.7712 2 18.6569 2 19.8284 3.17157C21 4.34315 21 6.22876 21 10V14C21 17.7712 21 19.6569 19.8284 20.8284C18.6569 22 16.7712 22 13 22H11C7.22876 22 5.34315 22 4.17157 20.8284C3 19.6569 3 17.7712 3 14V10Z"/><path fill="currentColor" d="M7.25 12C7.25 11.5858 7.58579 11.25 8 11.25H16C16.4142 11.25 16.75 11.5858 16.75 12C16.75 12.4142 16.4142 12.75 16 12.75H8C7.58579 12.75 7.25 12.4142 7.25 12Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M7.25 8C7.25 7.58579 7.58579 7.25 8 7.25H16C16.4142 7.25 16.75 7.58579 16.75 8C16.75 8.41421 16.4142 8.75 16 8.75H8C7.58579 8.75 7.25 8.41421 7.25 8Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M7.25 16C7.25 15.5858 7.58579 15.25 8 15.25H13C13.4142 15.25 13.75 15.5858 13.75 16C13.75 16.4142 13.4142 16.75 13 16.75H8C7.58579 16.75 7.25 16.4142 7.25 16Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
gallery: `<path fill="currentColor" d="M18 8C18 9.10457 17.1046 10 16 10C14.8954 10 14 9.10457 14 8C14 6.89543 14.8954 6 16 6C17.1046 6 18 6.89543 18 8Z"/><path fill="currentColor" d="M11.9426 1.25H12.0574C14.3658 1.24999 16.1748 1.24998 17.5863 1.43975C19.031 1.63399 20.1711 2.03933 21.0659 2.93414C21.9607 3.82895 22.366 4.96897 22.5603 6.41371C22.75 7.82519 22.75 9.63423 22.75 11.9426V12.0309C22.75 13.9397 22.75 15.5023 22.6463 16.7745C22.5422 18.0531 22.3287 19.1214 21.8509 20.0087C21.6401 20.4001 21.3812 20.7506 21.0659 21.0659C20.1711 21.9607 19.031 22.366 17.5863 22.5603C16.1748 22.75 14.3658 22.75 12.0574 22.75H11.9426C9.63423 22.75 7.82519 22.75 6.41371 22.5603C4.96897 22.366 3.82895 21.9607 2.93414 21.0659C2.14086 20.2726 1.7312 19.2852 1.51335 18.0604C1.29935 16.8573 1.2602 15.3603 1.25207 13.5015C1.25 13.0287 1.25 12.5286 1.25 12.001L1.25 11.9426C1.24999 9.63423 1.24998 7.82519 1.43975 6.41371C1.63399 4.96897 2.03933 3.82895 2.93414 2.93414C3.82895 2.03933 4.96897 1.63399 6.41371 1.43975C7.82519 1.24998 9.63423 1.24999 11.9426 1.25ZM6.61358 2.92637C5.33517 3.09825 4.56445 3.42514 3.9948 3.9948C3.42514 4.56445 3.09825 5.33517 2.92637 6.61358C2.75159 7.91356 2.75 9.62177 2.75 12C2.75 12.5287 2.75 13.0257 2.75205 13.4949C2.76025 15.369 2.80214 16.7406 2.99017 17.7978C3.17436 18.8333 3.48774 19.4981 3.9948 20.0052C4.56445 20.5749 5.33517 20.9018 6.61358 21.0736C7.91356 21.2484 9.62177 21.25 12 21.25C14.3782 21.25 16.0864 21.2484 17.3864 21.0736C18.6648 20.9018 19.4355 20.5749 20.0052 20.0052C20.2151 19.7953 20.3872 19.5631 20.5302 19.2976C20.8619 18.6816 21.0531 17.8578 21.1513 16.6527C21.2494 15.4482 21.25 13.9459 21.25 12C21.25 9.62177 21.2484 7.91356 21.0736 6.61358C20.9018 5.33517 20.5749 4.56445 20.0052 3.9948C19.4355 3.42514 18.6648 3.09825 17.3864 2.92637C16.0864 2.75159 14.3782 2.75 12 2.75C9.62177 2.75 7.91356 2.75159 6.61358 2.92637Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" opacity="0.4" d="M20.6069 19.1463L17.7765 16.599C16.737 15.6634 15.1889 15.5702 14.0446 16.3744L13.7464 16.5839C12.9513 17.1428 11.8695 17.0491 11.1822 16.3618L6.89252 12.0721C6.03631 11.2159 4.66289 11.1702 3.75162 11.9675L2.75049 12.8435C2.75077 13.0665 2.75128 13.2835 2.7522 13.4949C2.7604 15.369 2.80229 16.7406 2.99032 17.7978C3.17451 18.8333 3.48788 19.4981 3.99494 20.0052C4.5646 20.5749 5.33532 20.9018 6.61372 21.0736C7.9137 21.2484 9.62192 21.25 12.0001 21.25C14.3784 21.25 16.0866 21.2484 17.3866 21.0736C18.665 20.9018 19.4357 20.5749 20.0054 20.0052C20.2153 19.7953 20.3873 19.5631 20.5303 19.2976C20.5568 19.2485 20.5823 19.1981 20.6069 19.1463Z"/>`,
|
||||
musicNote: `<path fill="currentColor" d="M10.0905 11.9629L19.3632 8.63087L20.9996 7.95235V7.49236C20.9996 6.37238 20.9996 5.4331 20.9118 4.68472C20.8994 4.57895 20.8848 4.4738 20.8686 4.37569C20.7841 3.86441 20.6348 3.38745 20.3465 2.98917C20.2024 2.79002 20.0235 2.61055 19.8007 2.45628C19.7589 2.42736 19.7156 2.39932 19.6707 2.3722L19.6617 2.36679C18.8901 1.90553 18.0228 1.93852 17.1293 2.14305C16.2652 2.34086 15.194 2.74368 13.8803 3.23763L11.5959 4.09656C10.9801 4.32806 10.4584 4.52419 10.049 4.72734C9.61332 4.94348 9.23805 5.1984 8.95662 5.57828C8.67519 5.95817 8.55831 6.36756 8.50457 6.81203C8.45406 7.22978 8.45408 7.7378 8.4541 8.33743V12.6016L10.0905 11.9629Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M8.45455 16.1305C7.90347 15.8136 7.24835 15.6298 6.54545 15.6298C4.58735 15.6298 3 17.0558 3 18.8148C3 20.5738 4.58735 21.9998 6.54545 21.9998C8.50355 21.9998 10.0909 20.5738 10.0909 18.8148L10.0909 11.9627L8.45455 12.6014V16.1305Z"/><path fill="currentColor" d="M19.3636 8.63067V14.1705C18.8126 13.8536 18.1574 13.6698 17.4545 13.6698C15.4964 13.6698 13.9091 15.0958 13.9091 16.8548C13.9091 18.6138 15.4964 20.0398 17.4545 20.0398C19.4126 20.0398 21 18.6138 21 16.8548L21 7.95215L19.3636 8.63067Z"/>`,
|
||||
videocamera: `<path fill="currentColor" opacity="0.5" d="M2 11.5C2 8.21252 2 6.56878 2.90796 5.46243C3.07418 5.25989 3.25989 5.07418 3.46243 4.90796C4.56878 4 6.21252 4 9.5 4C12.7875 4 14.4312 4 15.5376 4.90796C15.7401 5.07418 15.9258 5.25989 16.092 5.46243C17 6.56878 17 8.21252 17 11.5V12.5C17 15.7875 17 17.4312 16.092 18.5376C15.9258 18.7401 15.7401 18.9258 15.5376 19.092C14.4312 20 12.7875 20 9.5 20C6.21252 20 4.56878 20 3.46243 19.092C3.25989 18.9258 3.07418 18.7401 2.90796 18.5376C2 17.4312 2 15.7875 2 12.5V11.5Z"/><path fill="currentColor" d="M17 9.50019L17.6584 9.17101C19.6042 8.19807 20.5772 7.7116 21.2886 8.15127C22 8.59094 22 9.67872 22 11.8543V12.1461C22 14.3217 22 15.4094 21.2886 15.8491C20.5772 16.2888 19.6042 15.8023 17.6584 14.8294L17 14.5002V9.50019Z"/>`,
|
||||
ssd: `<path fill="currentColor" opacity="0.5" d="M5.49965 13.5882H18.4996C19.8107 13.5882 20.9534 14.3515 21.553 15.4813L18.9996 5.11765C18.4996 3.52941 17.6042 3 16.4996 3H7.49965C6.39508 3 5.49965 3.52941 4.99965 5.11765L2.44629 15.4813C3.04588 14.3515 4.18858 13.5882 5.49965 13.5882Z"/><path fill="currentColor" d="M18.5 13.5879H5.5C4.18893 13.5879 3.04623 14.3512 2.44664 15.4809C2.16221 16.0169 2 16.6353 2 17.2938C2 19.3405 3.567 20.9997 5.5 20.9997H18.5C20.433 20.9997 22 19.3405 22 17.2938C22 16.6353 21.8378 16.0169 21.5534 15.4809C20.9538 14.3512 19.8111 13.5879 18.5 13.5879ZM18 16.25C18.4142 16.25 18.75 16.5858 18.75 17V18C18.75 18.4142 18.4142 18.75 18 18.75C17.5858 18.75 17.25 18.4142 17.25 18V17C17.25 16.5858 17.5858 16.25 18 16.25ZM16.25 17C16.25 16.5858 15.9142 16.25 15.5 16.25C15.0858 16.25 14.75 16.5858 14.75 17V18C14.75 18.4142 15.0858 18.75 15.5 18.75C15.9142 18.75 16.25 18.4142 16.25 18V17ZM13 16.25C13.4142 16.25 13.75 16.5858 13.75 17V18C13.75 18.4142 13.4142 18.75 13 18.75C12.5858 18.75 12.25 18.4142 12.25 18V17C12.25 16.5858 12.5858 16.25 13 16.25ZM11.25 17C11.25 16.5858 10.9142 16.25 10.5 16.25C10.0858 16.25 9.75 16.5858 9.75 17V18C9.75 18.4142 10.0858 18.75 10.5 18.75C10.9142 18.75 11.25 18.4142 11.25 18V17Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
database: `<path fill="currentColor" d="M12 10C16.4183 10 20 8.20914 20 6C20 3.79086 16.4183 2 12 2C7.58172 2 4 3.79086 4 6C4 8.20914 7.58172 10 12 10Z"/><path fill="currentColor" opacity="0.5" d="M4 12V18C4 20.2091 7.58172 22 12 22C16.4183 22 20 20.2091 20 18V12C20 14.2091 16.4183 16 12 16C7.58172 16 4 14.2091 4 12Z"/><path fill="currentColor" opacity="0.7" d="M4 6V12C4 14.2091 7.58172 16 12 16C16.4183 16 20 14.2091 20 12V6C20 8.20914 16.4183 10 12 10C7.58172 10 4 8.20914 4 6Z"/>`,
|
||||
star: `<path fill="currentColor" d="M18.4834 16.7674C17.8471 16.9195 17.1829 17 16.5 17C11.8056 17 8 13.1944 8 8.50001C8 8.01653 8.04036 7.54249 8.11791 7.08105C8.08172 7.11586 8.04432 7.14792 8.00494 7.17781C7.72433 7.39083 7.37485 7.46991 6.67589 7.62806L6.03954 7.77204C3.57986 8.32856 2.35002 8.60682 2.05742 9.54774C1.76482 10.4887 2.60325 11.4691 4.2801 13.4299L4.71392 13.9372C5.19042 14.4944 5.42868 14.773 5.53586 15.1177C5.64305 15.4624 5.60703 15.8341 5.53498 16.5776L5.4694 17.2544C5.21588 19.8706 5.08912 21.1787 5.85515 21.7602C6.62117 22.3417 7.77267 21.8116 10.0757 20.7512L10.6715 20.4768C11.3259 20.1755 11.6531 20.0249 12 20.0249C12.3469 20.0249 12.6741 20.1755 13.3285 20.4768L13.9243 20.7512C16.2273 21.8116 17.3788 22.3417 18.1449 21.7602C18.9109 21.1787 18.7841 19.8706 18.5306 17.2544L18.4834 16.7674Z"/><path fill="currentColor" opacity="0.5" d="M9.15302 5.40838L8.82532 5.99623C8.46538 6.64194 8.28541 6.96479 8.0048 7.17781C8.04418 7.14791 8.08158 7.11586 8.11777 7.08105C8.04022 7.54249 7.99986 8.01653 7.99986 8.50001C7.99986 13.1944 11.8054 17 16.4999 17C17.1828 17 17.8469 16.9195 18.4833 16.7674L18.4649 16.5776C18.3928 15.8341 18.3568 15.4624 18.464 15.1177C18.5712 14.773 18.8094 14.4944 19.2859 13.9372L19.7198 13.4299C21.3966 11.4691 22.235 10.4886 21.9424 9.54773C21.6498 8.60682 20.42 8.32856 17.9603 7.77203L17.324 7.62805C16.625 7.4699 16.2755 7.39083 15.9949 7.17781C15.7143 6.96479 15.5343 6.64194 15.1744 5.99624L14.8467 5.40837C13.58 3.13612 12.9467 2 11.9999 2C11.053 2 10.4197 3.13613 9.15302 5.40838Z"/>`,
|
||||
link: `<path fill="currentColor" d="M15.7281 3.88396C17.1624 2.44407 19.2604 2.41383 20.4219 3.57981C21.5856 4.74798 21.5542 6.85922 20.1189 8.30009L17.6951 10.7333C17.4028 11.0268 17.4037 11.5017 17.6971 11.794C17.9906 12.0863 18.4655 12.0854 18.7578 11.7919L21.1816 9.35869C23.0929 7.43998 23.3329 4.37665 21.4846 2.5212C19.6342 0.663551 16.5776 0.905664 14.6653 2.82536L9.81768 7.69182C7.90639 9.61053 7.66643 12.6739 9.5147 14.5293C9.80702 14.8228 10.2819 14.8237 10.5754 14.5314C10.8688 14.2391 10.8697 13.7642 10.5774 13.4707C9.41376 12.3026 9.4451 10.1913 10.8804 8.75042L15.7281 3.88396Z"/><path fill="currentColor" opacity="0.5" d="M14.4846 9.4707C14.1923 9.17724 13.7174 9.17632 13.4239 9.46864C13.1305 9.76097 13.1296 10.2358 13.4219 10.5293C14.5856 11.6975 14.5542 13.8087 13.1189 15.2496L8.27129 20.1161C6.83696 21.556 4.73889 21.5862 3.57742 20.4202C2.41376 19.2521 2.4451 17.1408 3.8804 15.6999L6.30424 13.2666C6.59657 12.9732 6.59565 12.4983 6.30219 12.206C6.00873 11.9137 5.53386 11.9146 5.24153 12.208L2.81769 14.6413C0.906387 16.56 0.666428 19.6234 2.5147 21.4788C4.36518 23.3365 7.42173 23.0944 9.334 21.1747L14.1816 16.3082C16.0929 14.3895 16.3329 11.3262 14.4846 9.4707Z"/>`,
|
||||
lockPassword: `<path fill="currentColor" opacity="0.5" d="M2 16C2 13.1716 2 11.7574 2.87868 10.8787C3.75736 10 5.17157 10 8 10H16C18.8284 10 20.2426 10 21.1213 10.8787C22 11.7574 22 13.1716 22 16C22 18.8284 22 20.2426 21.1213 21.1213C20.2426 22 18.8284 22 16 22H8C5.17157 22 3.75736 22 2.87868 21.1213C2 20.2426 2 18.8284 2 16Z"/><path fill="currentColor" d="M8 17C8.55228 17 9 16.5523 9 16C9 15.4477 8.55228 15 8 15C7.44772 15 7 15.4477 7 16C7 16.5523 7.44772 17 8 17Z"/><path fill="currentColor" d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z"/><path fill="currentColor" d="M17 16C17 16.5523 16.5523 17 16 17C15.4477 17 15 16.5523 15 16C15 15.4477 15.4477 15 16 15C16.5523 15 17 15.4477 17 16Z"/><path fill="currentColor" d="M6.75 8C6.75 5.10051 9.10051 2.75 12 2.75C14.8995 2.75 17.25 5.10051 17.25 8V10.0036C17.8174 10.0089 18.3135 10.022 18.75 10.0546V8C18.75 4.27208 15.7279 1.25 12 1.25C8.27208 1.25 5.25 4.27208 5.25 8V10.0546C5.68651 10.022 6.18264 10.0089 6.75 10.0036V8Z"/>`,
|
||||
checkCircle: `<path fill="currentColor" opacity="0.5" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"/><path fill="currentColor" d="M16.0303 8.96967C16.3232 9.26256 16.3232 9.73744 16.0303 10.0303L11.0303 15.0303C10.7374 15.3232 10.2626 15.3232 9.96967 15.0303L7.96967 13.0303C7.67678 12.7374 7.67678 12.2626 7.96967 11.9697C8.26256 11.6768 8.73744 11.6768 9.03033 11.9697L10.5 13.4393L12.7348 11.2045L14.9697 8.96967C15.2626 8.67678 15.7374 8.67678 16.0303 8.96967Z"/>`,
|
||||
playCircle: `<path fill="currentColor" opacity="0.5" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M15.4137 13.059L10.6935 15.8458C9.93371 16.2944 9 15.7105 9 14.7868V9.21316C9 8.28947 9.93371 7.70561 10.6935 8.15419L15.4137 10.941C16.1954 11.4026 16.1954 12.5974 15.4137 13.059Z"/>`,
|
||||
pauseCircle: `<path fill="currentColor" opacity="0.5" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"/><path fill="currentColor" d="M8.07612 8.61732C8 8.80109 8 9.03406 8 9.5V14.5C8 14.9659 8 15.1989 8.07612 15.3827C8.17761 15.6277 8.37229 15.8224 8.61732 15.9239C8.80109 16 9.03406 16 9.5 16C9.96594 16 10.1989 16 10.3827 15.9239C10.6277 15.8224 10.8224 15.6277 10.9239 15.3827C11 15.1989 11 14.9659 11 14.5V9.5C11 9.03406 11 8.80109 10.9239 8.61732C10.8224 8.37229 10.6277 8.17761 10.3827 8.07612C10.1989 8 9.96594 8 9.5 8C9.03406 8 8.80109 8 8.61732 8.07612C8.37229 8.17761 8.17761 8.37229 8.07612 8.61732Z"/><path fill="currentColor" d="M13.0761 8.61732C13 8.80109 13 9.03406 13 9.5V14.5C13 14.9659 13 15.1989 13.0761 15.3827C13.1776 15.6277 13.3723 15.8224 13.6173 15.9239C13.8011 16 14.0341 16 14.5 16C14.9659 16 15.1989 16 15.3827 15.9239C15.6277 15.8224 15.8224 15.6277 15.9239 15.3827C16 15.1989 16 14.9659 16 14.5V9.5C16 9.03406 16 8.80109 15.9239 8.61732C15.8224 8.37229 15.6277 8.17761 15.3827 8.07612C15.1989 8 14.9659 8 14.5 8C14.0341 8 13.8011 8 13.6173 8.07612C13.3723 8.17761 13.1776 8.37229 13.0761 8.61732Z"/>`,
|
||||
closeCircle: `<path fill="currentColor" d="M8.96967 8.96967C9.26256 8.67678 9.73744 8.67678 10.0303 8.96967L12 10.9394L13.9697 8.96969C14.2626 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0304L13.0607 12L15.0303 13.9696C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0304 15.0303C9.73746 15.3232 9.26258 15.3232 8.96969 15.0303C8.6768 14.7374 8.6768 14.2626 8.96969 13.9697L10.9394 12L8.96967 10.0303C8.67678 9.73744 8.67678 9.26256 8.96967 8.96967Z"/>`,
|
||||
arrowUp: `<path fill="currentColor" opacity="0.5" d="M12 20.75C12.4142 20.75 12.75 20.4142 12.75 20L12.75 10.75L11.25 10.75L11.25 20C11.25 20.4142 11.5858 20.75 12 20.75Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M6.00002 10.75C5.69667 10.75 5.4232 10.5673 5.30711 10.287C5.19103 10.0068 5.25519 9.68417 5.46969 9.46967L11.4697 3.46967C11.6103 3.32902 11.8011 3.25 12 3.25C12.1989 3.25 12.3897 3.32902 12.5304 3.46967L18.5304 9.46967C18.7449 9.68417 18.809 10.0068 18.6929 10.287C18.5768 10.5673 18.3034 10.75 18 10.75L6.00002 10.75Z"/>`,
|
||||
altArrowUp: `<path fill="currentColor" d="M8.30273 11.5956L11.6296 8.16485C11.8428 7.94505 12.1573 7.94505 12.3704 8.16485L18.8001 14.7953C19.2013 15.2091 18.9581 16 18.4297 16H12.7071L8.30273 11.5956Z"/><path fill="currentColor" opacity="0.5" d="M11.2929 15.9999H5.5703C5.04189 15.9999 4.79869 15.2089 5.1999 14.7952L7.60648 12.3135L11.2929 15.9999Z"/>`,
|
||||
altArrowDown: `<path fill="currentColor" d="M8.30273 12.4044L11.6296 15.8351C11.8428 16.0549 12.1573 16.0549 12.3704 15.8351L18.8001 9.20467C19.2013 8.79094 18.9581 8 18.4297 8H12.7071L8.30273 12.4044Z"/><path fill="currentColor" opacity="0.5" d="M11.2929 8H5.5703C5.04189 8 4.79869 8.79094 5.1999 9.20467L7.60648 11.6864L11.2929 8Z"/>`,
|
||||
code: `<path fill="currentColor" d="M16.4425 7.32787C16.7196 7.01999 17.1938 6.99503 17.5017 7.27213L19.2392 8.83587C19.9756 9.49853 20.5864 10.0482 21.0058 10.5468C21.4468 11.071 21.7603 11.6343 21.7603 12.3296C21.7603 13.0249 21.4468 13.5882 21.0058 14.1124C20.5864 14.611 19.9756 15.1607 19.2392 15.8233L17.5017 17.3871C17.1938 17.6642 16.7196 17.6392 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7453C18.9826 14.0362 19.5138 13.5558 19.8579 13.1468C20.1882 12.7542 20.2603 12.525 20.2603 12.3296C20.2603 12.1342 20.1882 11.905 19.8579 11.5124C19.5138 11.1034 18.9826 10.623 18.1947 9.91389L16.4983 8.38707C16.1904 8.10997 16.1654 7.63576 16.4425 7.32787Z"/><path fill="currentColor" d="M7.50178 8.38707C7.80966 8.10997 7.83462 7.63576 7.55752 7.32787C7.28043 7.01999 6.80621 6.99503 6.49833 7.27213L4.76084 8.83587C4.0245 9.49853 3.41369 10.0482 2.99428 10.5468C2.55325 11.071 2.23975 11.6343 2.23975 12.3296C2.23975 13.0249 2.55325 13.5882 2.99428 14.1124C3.41369 14.611 4.02449 15.1607 4.76082 15.8233L6.49833 17.3871C6.80621 17.6642 7.28043 17.6392 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7453C5.01743 14.0362 4.48623 13.5558 4.14213 13.1468C3.81188 12.7542 3.73975 12.525 3.73975 12.3296C3.73975 12.1342 3.81188 11.905 4.14213 11.5124C4.48623 11.1034 5.01743 10.623 5.80531 9.91389L7.50178 8.38707Z"/><path fill="currentColor" opacity="0.5" d="M14.1816 4.2755C14.5817 4.3827 14.8191 4.79396 14.7119 5.19406L10.7383 20.0238C10.6311 20.4239 10.2198 20.6613 9.81974 20.5541C9.41964 20.4469 9.18221 20.0356 9.28941 19.6355L13.263 4.80583C13.3702 4.40573 13.7815 4.16829 14.1816 4.2755Z"/>`,
|
||||
archive: `<path fill="currentColor" d="M11.5 21H12.5C16.2712 21 18.1569 21 19.3284 19.8284C20.5 18.6569 20.5 16.7713 20.5 13V6.99805C20.3548 7.00008 20.1509 7.00005 20 7.00002H4C3.84905 7.00005 3.6452 7.00008 3.5 6.99805V13C3.5 16.7713 3.5 18.6569 4.67157 19.8284C5.84315 21 7.72876 21 11.5 21ZM9.07612 11.1173C9 11.3011 9 11.5341 9 12C9 12.4659 9 12.6989 9.07612 12.8827C9.17761 13.1277 9.37229 13.3224 9.61732 13.4239C9.80109 13.5 10.0341 13.5 10.5 13.5H13.5C13.9659 13.5 14.1989 13.5 14.3827 13.4239C14.6277 13.3224 14.8224 13.1277 14.9239 12.8827C15 12.6989 15 12.4659 15 12C15 11.5341 15 11.3011 14.9239 11.1173C14.8224 10.8723 14.6277 10.6776 14.3827 10.5761C14.1989 10.5 13.9659 10.5 13.5 10.5H10.5C10.0341 10.5 9.80109 10.5 9.61732 10.5761C9.37229 10.6776 9.17761 10.8723 9.07612 11.1173Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M2 5C2 4.05719 2 3.58579 2.29289 3.29289C2.58579 3 3.05719 3 4 3H20C20.9428 3 21.4142 3 21.7071 3.29289C22 3.58579 22 4.05719 22 5C22 5.94281 22 6.41421 21.7071 6.70711C21.4142 7 20.9428 7 20 7H4C3.05719 7 2.58579 7 2.29289 6.70711C2 6.41421 2 5.94281 2 5Z"/>`,
|
||||
shieldKeyhole: `<path fill="currentColor" opacity="0.5" d="M3 10.4167C3 7.21907 3 5.62028 3.37752 5.08241C3.75503 4.54454 5.25832 4.02996 8.26491 3.00079L8.83772 2.80472C10.405 2.26824 11.1886 2 12 2C12.8114 2 13.595 2.26824 15.1623 2.80472L15.7351 3.00079C18.7417 4.02996 20.245 4.54454 20.6225 5.08241C21 5.62028 21 7.21907 21 10.4167V11.9914C21 17.6294 16.761 20.3655 14.1014 21.5273C13.38 21.8424 13.0193 22 12 22C10.9807 22 10.62 21.8424 9.89856 21.5273C7.23896 20.3655 3 17.6294 3 11.9914V10.4167Z"/><path fill="currentColor" d="M13.5 15C13.5 15.5523 13.0523 16 12.5 16H11.5C10.9477 16 10.5 15.5523 10.5 15V13.5987C9.6033 13.0799 9 12.1104 9 11C9 9.34315 10.3431 8 12 8C13.6569 8 15 9.34315 15 11C15 12.1104 14.3967 13.0799 13.5 13.5987V15Z"/>`,
|
||||
file: `<path fill="currentColor" d="M14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.23869 2 10.0298 2C10.6358 2 11.1214 2 11.53 2.01666C11.5166 2.09659 11.5095 2.17813 11.5092 2.26057L11.5 5.09497C11.4999 6.19207 11.4998 7.16164 11.6049 7.94316C11.7188 8.79028 11.9803 9.63726 12.6716 10.3285C13.3628 11.0198 14.2098 11.2813 15.0569 11.3952C15.8385 11.5003 16.808 11.5002 17.9051 11.5001L18 11.5001H21.9574C22 12.0344 22 12.6901 22 13.5629V14C22 17.7712 22 19.6569 20.8284 20.8284C19.6569 22 17.7712 22 14 22Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M11.5092 2.2601L11.5 5.0945C11.4999 6.1916 11.4998 7.16117 11.6049 7.94269C11.7188 8.78981 11.9803 9.6368 12.6716 10.3281C13.3629 11.0193 14.2098 11.2808 15.057 11.3947C15.8385 11.4998 16.808 11.4997 17.9051 11.4996L21.9574 11.4996C21.9698 11.6552 21.9786 11.821 21.9848 11.9995H22C22 11.732 22 11.5983 21.9901 11.4408C21.9335 10.5463 21.5617 9.52125 21.0315 8.79853C20.9382 8.6713 20.8743 8.59493 20.7467 8.44218C19.9542 7.49359 18.911 6.31193 18 5.49953C17.1892 4.77645 16.0787 3.98536 15.1101 3.3385C14.2781 2.78275 13.862 2.50487 13.2915 2.29834C13.1403 2.24359 12.9408 2.18311 12.7846 2.14466C12.4006 2.05013 12.0268 2.01725 11.5 2.00586L11.5092 2.2601Z"/>`,
|
||||
fileText: `<path fill="currentColor" d="M14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.23869 2 10.0298 2C10.6358 2 11.1214 2 11.53 2.01666C11.5166 2.09659 11.5095 2.17813 11.5092 2.26057L11.5 5.09497C11.4999 6.19207 11.4998 7.16164 11.6049 7.94316C11.7188 8.79028 11.9803 9.63726 12.6716 10.3285C13.3628 11.0198 14.2098 11.2813 15.0569 11.3952C15.8385 11.5003 16.808 11.5002 17.9051 11.5001L18 11.5001H21.9574C22 12.0344 22 12.6901 22 13.5629V14C22 17.7712 22 19.6569 20.8284 20.8284C19.6569 22 17.7712 22 14 22Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M6 13.75C5.58579 13.75 5.25 14.0858 5.25 14.5C5.25 14.9142 5.58579 15.25 6 15.25H14C14.4142 15.25 14.75 14.9142 14.75 14.5C14.75 14.0858 14.4142 13.75 14 13.75H6Z"/><path fill="currentColor" d="M6 17.25C5.58579 17.25 5.25 17.5858 5.25 18C5.25 18.4142 5.58579 18.75 6 18.75H11.5C11.9142 18.75 12.25 18.4142 12.25 18C12.25 17.5858 11.9142 17.25 11.5 17.25H6Z"/><path fill="currentColor" d="M11.5092 2.2601L11.5 5.0945C11.4999 6.1916 11.4998 7.16117 11.6049 7.94269C11.7188 8.78981 11.9803 9.6368 12.6716 10.3281C13.3629 11.0193 14.2098 11.2808 15.057 11.3947C15.8385 11.4998 16.808 11.4997 17.9051 11.4996L21.9574 11.4996C21.9698 11.6552 21.9786 11.821 21.9848 11.9995H22C22 11.732 22 11.5983 21.9901 11.4408C21.9335 10.5463 21.5617 9.52125 21.0315 8.79853C20.9382 8.6713 20.8743 8.59493 20.7467 8.44218C19.9542 7.49359 18.911 6.31193 18 5.49953C17.1892 4.77645 16.0787 3.98536 15.1101 3.3385C14.2781 2.78275 13.862 2.50487 13.2915 2.29834C13.1403 2.24359 12.9408 2.18311 12.7846 2.14466C12.4006 2.05013 12.0268 2.01725 11.5 2.00586L11.5092 2.2601Z"/>`,
|
||||
eye: `<path fill="currentColor" opacity="0.5" d="M2 12C2 13.6394 2.42496 14.1915 3.27489 15.2957C4.97196 17.5004 7.81811 20 12 20C16.1819 20 19.028 17.5004 20.7251 15.2957C21.575 14.1915 22 13.6394 22 12C22 10.3606 21.575 9.80853 20.7251 8.70433C19.028 6.49956 16.1819 4 12 4C7.81811 4 4.97196 6.49956 3.27489 8.70433C2.42496 9.80853 2 10.3606 2 12Z"/><path fill="currentColor" d="M8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12ZM9.75 12C9.75 10.7574 10.7574 9.75 12 9.75C13.2426 9.75 14.25 10.7574 14.25 12C14.25 13.2426 13.2426 14.25 12 14.25C10.7574 14.25 9.75 13.2426 9.75 12Z" fill-rule="evenodd" clip-rule="evenodd"/>`,
|
||||
eyeClosed: `<path fill="currentColor" d="M2.91858 6.60465C2.70062 6.09784 2.11327 5.86324 1.60603 6.08063C1.0984 6.29818 0.863613 6.8869 1.08117 7.39453L1.0816 7.39553L1.08267 7.39802L1.08566 7.4049L1.09505 7.42618C1.10282 7.44366 1.11363 7.46765 1.12752 7.49772C1.15529 7.55783 1.19539 7.64235 1.2481 7.74777C1.35345 7.95845 1.5096 8.25357 1.71879 8.605C2.12772 9.29201 2.74529 10.2043 3.59029 11.1241L2.79285 11.9215C2.40232 12.312 2.40232 12.9452 2.79285 13.3357C3.18337 13.7262 3.81654 13.7262 4.20706 13.3357L5.04746 12.4953C5.61245 12.9515 6.24405 13.3814 6.94417 13.7519L6.16177 14.9544C5.86056 15.4173 5.99165 16.0367 6.45457 16.338C6.91748 16.6392 7.53693 16.5081 7.83814 16.0452L8.82334 14.531C9.50014 14.7386 10.2253 14.8864 11 14.9556V16.4998C11 17.0521 11.4477 17.4998 12 17.4998V12.9998C9.25227 12.9998 7.18102 11.8012 5.69633 10.4109C5.68823 10.4031 5.68003 10.3954 5.67173 10.3878C5.47324 10.2009 5.28532 10.0105 5.10775 9.81932C4.35439 9.00801 3.80137 8.19355 3.43737 7.58204C3.25594 7.27722 3.12302 7.02546 3.03696 6.85334C2.99397 6.76735 2.96278 6.70147 2.94319 6.65905C2.93339 6.63785 2.92651 6.62253 2.9225 6.61352L2.91858 6.60465ZM1.08117 7.39453L1.99995 6.99977C1.08081 7.39369 1.08117 7.39453 1.08117 7.39453Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" opacity="0.5" d="M15.2209 12.3984C14.2784 12.7694 13.209 13.0002 12 13.0002V17.5002C12.5523 17.5002 13 17.0525 13 16.5002V14.9559C13.772 14.8867 14.4974 14.7392 15.1764 14.5311L16.1618 16.0456C16.463 16.5085 17.0825 16.6396 17.5454 16.3384C18.0083 16.0372 18.1394 15.4177 17.8382 14.9548L17.0558 13.7524C17.757 13.3816 18.3885 12.9517 18.9527 12.496L19.7929 13.3361C20.1834 13.7267 20.8166 13.7267 21.2071 13.3361C21.5976 12.9456 21.5976 12.3124 21.2071 11.9219L20.4097 11.1245C21.1521 10.3164 21.7181 9.51502 22.1207 8.86887C22.384 8.44627 22.5799 8.08609 22.7116 7.82793C22.7775 7.69874 22.8274 7.59476 22.8619 7.5209C22.8791 7.48397 22.8924 7.45453 22.902 7.4332L22.9134 7.40736L22.917 7.39913L22.9191 7.39411C23.1367 6.88648 22.9015 6.2986 22.3939 6.08105C21.8864 5.86355 21.2985 6.09892 21.0809 6.60627L21.0759 6.61747C21.0706 6.62926 21.0617 6.6489 21.0492 6.6758C21.0241 6.72962 20.9844 6.81235 20.9299 6.91928C20.8207 7.13337 20.6526 7.4431 20.4233 7.81119C19.9628 8.55023 19.2652 9.50857 18.3156 10.3999C17.4746 11.1893 16.4469 11.9158 15.2209 12.3984Z"/>`,
|
||||
key: `<path fill="currentColor" opacity="0.5" d="M22 8.29344C22 11.7692 19.1708 14.5869 15.6807 14.5869C15.0439 14.5869 13.5939 14.4405 12.8885 13.8551L12.0067 14.7333C11.4883 15.2496 11.6283 15.4016 11.8589 15.652C11.9551 15.7565 12.0672 15.8781 12.1537 16.0505C12.1537 16.0505 12.8885 17.075 12.1537 18.0995C11.7128 18.6849 10.4783 19.5045 9.06754 18.0995L8.77362 18.3922C8.77362 18.3922 9.65538 19.4167 8.92058 20.4412C8.4797 21.0267 7.30403 21.6121 6.27531 20.5876L5.2466 21.6121C4.54119 22.3146 3.67905 21.9048 3.33616 21.6121L2.45441 20.7339C1.63143 19.9143 2.1115 19.0264 2.45441 18.6849L10.0963 11.0743C10.0963 11.0743 9.3615 9.90338 9.3615 8.29344C9.3615 4.81767 12.1907 2 15.6807 2C19.1708 2 22 4.81767 22 8.29344Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M17.8853 8.29353C17.8853 9.50601 16.8984 10.4889 15.681 10.4889C14.4635 10.4889 13.4766 9.50601 13.4766 8.29353C13.4766 7.08105 14.4635 6.09814 15.681 6.09814C16.8984 6.09814 17.8853 7.08105 17.8853 8.29353Z"/>`,
|
||||
global: `<path fill="currentColor" d="M2.02783 11.25C2.41136 6.07745 6.72957 2 12.0001 2C11.1693 2 10.4295 2.36421 9.82093 2.92113C9.21541 3.47525 8.70371 4.24878 8.28983 5.16315C7.87352 6.08292 7.55013 7.15868 7.33126 8.32611C7.1558 9.26194 7.04903 10.2485 7.01344 11.25H2.02783ZM2.02783 12.75H7.01344C7.04903 13.7515 7.1558 14.7381 7.33126 15.6739C7.55013 16.8413 7.87351 17.9171 8.28983 18.8368C8.70371 19.7512 9.21541 20.5247 9.82093 21.0789C10.4295 21.6358 11.1693 22 12.0001 22C6.72957 22 2.41136 17.9226 2.02783 12.75Z" fill-rule="evenodd" clip-rule="evenodd"/><path fill="currentColor" d="M12.0001 2C12.831 2 13.5708 2.36421 14.1793 2.92113C14.7849 3.47525 15.2966 4.24878 15.7104 5.16315C16.1267 6.08292 16.4501 7.15868 16.669 8.32612C16.8445 9.26194 16.9512 10.2485 16.9868 11.25H21.9724C21.5889 6.07745 17.2707 2 12.0001 2Z"/><path fill="currentColor" d="M16.669 15.6739C16.4501 16.8413 16.1267 17.9171 15.7104 18.8368C15.2966 19.7512 14.7849 20.5247 14.1793 21.0789C13.5708 21.6358 12.831 22 12.0001 22C17.2707 22 21.5889 17.9226 21.9724 12.75H16.9868C16.9512 13.7515 16.8445 14.7381 16.669 15.6739Z"/><path fill="currentColor" d="M12.0002 3.39551C11.7252 3.39551 11.3699 3.51252 10.9568 3.89058C10.5406 4.27142 10.124 4.86831 9.7559 5.68153C9.39025 6.48936 9.09334 7.46456 8.88902 8.55435C8.72811 9.41258 8.62829 10.3223 8.59326 11.2502H15.4071C15.3721 10.3223 15.2723 9.41258 15.1113 8.55435C14.907 7.46457 14.6101 6.48937 14.2445 5.68153C13.8764 4.86831 13.4597 4.27142 13.0436 3.89058C12.6305 3.51252 12.2752 3.39551 12.0002 3.39551Z"/><path fill="currentColor" d="M8.88902 15.446C9.09334 16.5358 9.39025 17.511 9.7559 18.3188C10.124 19.132 10.5406 19.7289 10.9568 20.1097C11.3699 20.4878 11.7252 20.6048 12.0002 20.6048C12.2752 20.6048 12.6305 20.4878 13.0436 20.1097C13.4597 19.7289 13.8764 19.132 14.2445 18.3188C14.6101 17.511 14.907 16.5358 15.1113 15.446C15.2723 14.5877 15.3721 13.678 15.4071 12.7502H8.59326C8.62829 13.678 8.72811 14.5877 8.88902 15.446Z"/>`,
|
||||
shieldCheck: `<path fill="currentColor" opacity="0.5" d="M3.37752 5.08241C3 5.62028 3 7.21907 3 10.4167V11.9914C3 17.6294 7.23896 20.3655 9.89856 21.5273C10.62 21.8424 10.9807 22 12 22C13.0193 22 13.38 21.8424 14.1014 21.5273C16.761 20.3655 21 17.6294 21 11.9914V10.4167C21 7.21907 21 5.62028 20.6225 5.08241C20.245 4.54454 18.7417 4.02996 15.7351 3.00079L15.1623 2.80472C13.595 2.26824 12.8114 2 12 2C11.1886 2 10.405 2.26824 8.83772 2.80472L8.26491 3.00079C5.25832 4.02996 3.75503 4.54454 3.37752 5.08241Z"/><path fill="currentColor" d="M15.0595 10.4995C15.3353 10.1905 15.3085 9.71643 14.9995 9.44055C14.6905 9.16468 14.2164 9.19152 13.9406 9.5005L10.9286 12.8739L10.0595 11.9005C9.78359 11.5915 9.30947 11.5647 9.0005 11.8406C8.69152 12.1164 8.66468 12.5905 8.94055 12.8995L10.3691 14.4995C10.5114 14.6589 10.7149 14.75 10.9286 14.75C11.1422 14.75 11.3457 14.6589 11.488 14.4995L15.0595 10.4995Z"/>`,
|
||||
close: `<path fill="currentColor" opacity="0.5" d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22Z"/><path fill="currentColor" d="M8.96967 8.96967C9.26256 8.67678 9.73744 8.67678 10.0303 8.96967L12 10.9394L13.9697 8.96969C14.2626 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0303L13.0607 12L15.0303 13.9697C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0304 15.0303C9.73746 15.3232 9.26258 15.3232 8.96969 15.0303C8.6768 14.7374 8.6768 14.2626 8.96969 13.9697L10.9394 12L8.96967 10.0303C8.67678 9.73744 8.67678 9.26256 8.96967 8.96967Z"/>`,
|
||||
};
|
||||
110
frontend/js/icons.js
Normal file
110
frontend/js/icons.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Thin rendering helper around the Solar BoldDuotone icon paths from
|
||||
// icons-data.js. Every icon has two path layers: a full-opacity "detail"
|
||||
// layer and a 50%-opacity "soft" background layer.
|
||||
import { ICONS } from "./icons-data.js";
|
||||
|
||||
const softLayerCache = new Map();
|
||||
|
||||
function withSolidSecondaryLayer(body) {
|
||||
const cached = softLayerCache.get(body);
|
||||
if (cached) return cached;
|
||||
const processed = body.replace(/<path([^>]*)\/>/g, (match, attrs) => {
|
||||
if (!/\sopacity="/.test(attrs)) return match;
|
||||
const withoutOpacity = attrs.replace(/\s*opacity="[^"]*"/, "");
|
||||
return `<path${withoutOpacity} class="icon-soft"/>`;
|
||||
});
|
||||
softLayerCache.set(body, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
export function iconMarkup(name, size = 16) {
|
||||
const body = ICONS[name];
|
||||
if (!body) return "";
|
||||
return `<svg class="icon" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${withSolidSecondaryLayer(body)}</svg>`;
|
||||
}
|
||||
|
||||
export function iconEl(name, size = 16) {
|
||||
const wrap = document.createElement("span");
|
||||
wrap.style.display = "inline-flex";
|
||||
wrap.innerHTML = iconMarkup(name, size);
|
||||
return wrap.firstElementChild;
|
||||
}
|
||||
|
||||
// ── File-type icon lookup ──────────────────────────────────────────────
|
||||
|
||||
const FILE_ICONS = {
|
||||
pdf: ["fileText", "icon--important"],
|
||||
xlsx: ["document", "icon--important"],
|
||||
xls: ["document", "icon--important"],
|
||||
doc: ["document", "icon--important"],
|
||||
docx: ["document", "icon--important"],
|
||||
png: ["gallery", "icon--important"],
|
||||
jpg: ["gallery", "icon--important"],
|
||||
jpeg: ["gallery", "icon--important"],
|
||||
gif: ["gallery", "icon--important"],
|
||||
webp: ["gallery", "icon--important"],
|
||||
svg: ["gallery", "icon--important"],
|
||||
mp4: ["videocamera", "icon--important"],
|
||||
mkv: ["videocamera", "icon--important"],
|
||||
avi: ["videocamera", "icon--important"],
|
||||
mov: ["videocamera", "icon--important"],
|
||||
webm: ["videocamera", "icon--important"],
|
||||
mp3: ["musicNote", "icon--important"],
|
||||
ogg: ["musicNote", "icon--important"],
|
||||
flac: ["musicNote", "icon--important"],
|
||||
wav: ["musicNote", "icon--important"],
|
||||
m4a: ["musicNote", "icon--important"],
|
||||
zip: ["archive", "icon--important"],
|
||||
tar: ["archive", "icon--important"],
|
||||
gz: ["archive", "icon--important"],
|
||||
bz2: ["archive", "icon--important"],
|
||||
xz: ["archive", "icon--important"],
|
||||
"7z": ["archive", "icon--important"],
|
||||
yaml: ["code", "icon--important"],
|
||||
yml: ["code", "icon--important"],
|
||||
toml: ["code", "icon--important"],
|
||||
json: ["code", "icon--important"],
|
||||
php: ["code", "icon--important"],
|
||||
java: ["code", "icon--important"],
|
||||
class: ["code", "icon--important"],
|
||||
jar: ["code", "icon--important"],
|
||||
rs: ["code", "icon--important"],
|
||||
py: ["code", "icon--important"],
|
||||
sh: ["code", "icon--important"],
|
||||
bash: ["code", "icon--important"],
|
||||
fish: ["code", "icon--important"],
|
||||
zsh: ["code", "icon--important"],
|
||||
html: ["code", "icon--important"],
|
||||
css: ["code", "icon--important"],
|
||||
js: ["code", "icon--important"],
|
||||
ts: ["code", "icon--important"],
|
||||
sql: ["database", "icon--important"],
|
||||
db: ["database", "icon--important"],
|
||||
sqlite: ["database", "icon--important"],
|
||||
env: ["shieldKeyhole", "icon--important"],
|
||||
pem: ["key", "icon--important"],
|
||||
pub: ["key", "icon--important"],
|
||||
log: ["documentText", "icon--inactive"],
|
||||
htaccess: ["shieldKeyhole", "icon--inactive"],
|
||||
conf: ["settings", "icon--inactive"],
|
||||
ini: ["settings", "icon--inactive"],
|
||||
txt: ["documentText", "icon--inactive"],
|
||||
md: ["documentText", "icon--inactive"],
|
||||
};
|
||||
|
||||
export function fileIconFor(name) {
|
||||
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : "";
|
||||
return FILE_ICONS[ext] ?? ["file", "text-dim"];
|
||||
}
|
||||
|
||||
// ── Quick-access sidebar icon map ──────────────────────────────────────
|
||||
|
||||
export const QUICK_ACCESS_ICONS = {
|
||||
Home: "home",
|
||||
Desktop: "monitor",
|
||||
Documents: "document",
|
||||
Downloads: "downloadSquare",
|
||||
Pictures: "gallery",
|
||||
Music: "musicNote",
|
||||
Videos: "videocamera",
|
||||
};
|
||||
106
frontend/js/ipc.js
Normal file
106
frontend/js/ipc.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Thin, typed-by-convention wrapper around the Tauri command surface defined
|
||||
// in src/commands.rs / registered in src/lib.rs. One function per command —
|
||||
// nothing here should contain UI logic, only the invoke() call and the small
|
||||
// amount of shape-normalization Rust's serde output needs (tuples -> objects).
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
// --- Connections ------------------------------------------------------------
|
||||
|
||||
export const connect = (params) => invoke("connect", { params });
|
||||
export const disconnect = (connectionId) => invoke("disconnect", { connectionId });
|
||||
|
||||
// --- Remote file ops ---------------------------------------------------------
|
||||
|
||||
export const remoteList = (connectionId, path) => invoke("remote_list", { connectionId, path });
|
||||
export const remoteStat = (connectionId, path) => invoke("remote_stat", { connectionId, path });
|
||||
export const remoteMkdir = (connectionId, path) => invoke("remote_mkdir", { connectionId, path });
|
||||
export const remoteRename = (connectionId, from, to) =>
|
||||
invoke("remote_rename", { connectionId, from, to });
|
||||
export const remoteDelete = (connectionId, path) => invoke("remote_delete", { connectionId, path });
|
||||
|
||||
// --- Local file ops -----------------------------------------------------------
|
||||
|
||||
export const localList = (path) => invoke("local_list", { path });
|
||||
export const localHomeDir = () => invoke("local_home_dir");
|
||||
export const localMkdir = (path) => invoke("local_mkdir", { path });
|
||||
export const localRename = (from, to) => invoke("local_rename", { from, to });
|
||||
export const localDelete = (path) => invoke("local_delete", { path });
|
||||
export const localOpen = (path) => invoke("local_open", { path });
|
||||
export const localMoveInto = (srcPath, destDir) =>
|
||||
invoke("local_move_into", { srcPath, destDir });
|
||||
|
||||
// --- Transfers ----------------------------------------------------------------
|
||||
|
||||
export const enqueueTransfer = (kind, connectionId, localPath, remotePath, fileName, totalBytes) =>
|
||||
invoke("enqueue_transfer", {
|
||||
kind,
|
||||
connectionId,
|
||||
localPath,
|
||||
remotePath,
|
||||
fileName,
|
||||
totalBytes,
|
||||
});
|
||||
export const listTasks = () => invoke("list_tasks");
|
||||
export const pauseTask = (id) => invoke("pause_task", { id });
|
||||
export const resumeTask = (id) => invoke("resume_task", { id });
|
||||
export const cancelTask = (id) => invoke("cancel_task", { id });
|
||||
export const removeTask = (id) => invoke("remove_task", { id });
|
||||
export const setMaxConcurrent = (n) => invoke("set_max_concurrent", { n });
|
||||
|
||||
// --- Sites / bookmarks / history / prefs --------------------------------------
|
||||
|
||||
export const listSites = () => invoke("list_sites");
|
||||
export const saveSite = (site) => invoke("save_site", { site });
|
||||
export const deleteSite = (id) => invoke("delete_site", { id });
|
||||
|
||||
export const listBookmarks = () =>
|
||||
invoke("list_bookmarks").then((rows) => rows.map(([id, name, path]) => ({ id, name, path })));
|
||||
export const addBookmark = (name, path) => invoke("add_bookmark", { name, path });
|
||||
export const removeBookmark = (id) => invoke("remove_bookmark", { id });
|
||||
|
||||
// HistoryRow is a Rust tuple, serialized as a positional JSON array:
|
||||
// (host, port, username, connectedAt, connId, protocol, keyPath)
|
||||
export const listHistory = () =>
|
||||
invoke("list_history").then((rows) =>
|
||||
rows.map(([host, port, username, connectedAt, connId, protocol, keyPath]) => ({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
connectedAt,
|
||||
connId,
|
||||
protocol,
|
||||
keyPath,
|
||||
})),
|
||||
);
|
||||
export const clearHistory = () => invoke("clear_history");
|
||||
export const findHistoryConnId = (host, port, username) =>
|
||||
invoke("find_history_conn_id", { host, port, username });
|
||||
|
||||
export const getPref = (key) => invoke("get_pref", { key });
|
||||
export const setPref = (key, value) => invoke("set_pref", { key, value });
|
||||
|
||||
// Read-back of passwords is deliberately absent: the backend resolves them
|
||||
// from the system keychain at connect() time and never hands them to the UI.
|
||||
export const deletePassword = (siteId) => invoke("delete_password", { siteId });
|
||||
export const appDataDir = () => invoke("app_data_dir");
|
||||
export const platformInfo = () => invoke("platform_info");
|
||||
|
||||
// --- Events -------------------------------------------------------------------
|
||||
// Payloads here are snake_case on the wire (worker.rs's ProgressPayload/
|
||||
// StatePayload aren't #[serde(rename_all = "camelCase")]), unlike every other
|
||||
// struct crossing the IPC boundary -- normalize to camelCase right here so
|
||||
// nothing downstream has to remember the exception.
|
||||
|
||||
export function onTransferProgress(fn) {
|
||||
return listen("transfer-progress", (e) => {
|
||||
const p = e.payload;
|
||||
fn({ id: p.id, transferredBytes: p.transferred_bytes, speed: p.speed, etaSecs: p.eta_secs });
|
||||
});
|
||||
}
|
||||
|
||||
export function onTransferStateChanged(fn) {
|
||||
return listen("transfer-state-changed", (e) => {
|
||||
fn({ id: e.payload.id, state: e.payload.state });
|
||||
});
|
||||
}
|
||||
183
frontend/js/main.js
Normal file
183
frontend/js/main.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { state, subscribe, notify, isWelcome } from "./store.js";
|
||||
import * as ipc from "./ipc.js";
|
||||
import { initLocal } from "./fsActions.js";
|
||||
import { loadSettings } from "./settingsActions.js";
|
||||
import { initPlatform } from "./platform.js";
|
||||
import { initShortcuts } from "./shortcuts.js";
|
||||
import { renderWelcome, updateWelcome } from "./components/welcome.js";
|
||||
import { renderToolbar, updateToolbarIfNeeded } from "./components/toolbar.js";
|
||||
import { renderSidebar, updateSidebarIfNeeded } from "./components/sidebar.js";
|
||||
import { renderTabBar } from "./components/tabs.js";
|
||||
import { renderPanes, updatePanes } from "./components/paneLayout.js";
|
||||
import { renderQueuePanel, updateQueuePanelIfNeeded } from "./components/queuePanel.js";
|
||||
import { renderStatusBar, updateStatusBarIfNeeded } from "./components/statusBar.js";
|
||||
import { renderConnectDialog } from "./components/connectDialog.js";
|
||||
import { renderHistoryPopup } from "./components/historyPopup.js";
|
||||
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
|
||||
import { renderSettingsDialog } from "./components/settingsDialog.js";
|
||||
|
||||
const root = document.getElementById("screen-root");
|
||||
|
||||
let currentScreen = null; // 'welcome' | 'main'
|
||||
let currentLocale = "en";
|
||||
let welcomeEl = null;
|
||||
let mainShell = null; // { el, refs }
|
||||
|
||||
function buildWelcomeScreen() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "screen";
|
||||
root.appendChild(el);
|
||||
renderWelcome(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildMainScreen() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "screen app-shell";
|
||||
el.innerHTML = `
|
||||
<div class="toolbar" data-role="toolbar"></div>
|
||||
<div class="app-body">
|
||||
<div class="sidebar" data-role="sidebar"></div>
|
||||
<div class="main-content">
|
||||
<div class="tabbar" data-role="tabbar"></div>
|
||||
<div class="tab-panes" data-role="panes"></div>
|
||||
<div class="queue-panel" data-role="queue"></div>
|
||||
<div class="status-bar" data-role="statusbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.appendChild(el);
|
||||
|
||||
const refs = {
|
||||
toolbar: el.querySelector('[data-role="toolbar"]'),
|
||||
sidebar: el.querySelector('[data-role="sidebar"]'),
|
||||
tabbar: el.querySelector('[data-role="tabbar"]'),
|
||||
panes: el.querySelector('[data-role="panes"]'),
|
||||
queue: el.querySelector('[data-role="queue"]'),
|
||||
statusbar: el.querySelector('[data-role="statusbar"]'),
|
||||
};
|
||||
renderToolbar(refs.toolbar);
|
||||
renderSidebar(refs.sidebar);
|
||||
renderTabBar(refs.tabbar);
|
||||
renderPanes(refs.panes);
|
||||
renderQueuePanel(refs.queue);
|
||||
renderStatusBar(refs.statusbar);
|
||||
return { el, refs };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const welcome = isWelcome();
|
||||
const wantScreen = welcome ? "welcome" : "main";
|
||||
const localeChanged = state.settings.language !== currentLocale;
|
||||
|
||||
if (wantScreen !== currentScreen || localeChanged) {
|
||||
if (welcomeEl) {
|
||||
welcomeEl.remove();
|
||||
welcomeEl = null;
|
||||
}
|
||||
if (mainShell) {
|
||||
mainShell.el.remove();
|
||||
mainShell = null;
|
||||
}
|
||||
if (welcome) welcomeEl = buildWelcomeScreen();
|
||||
else {
|
||||
mainShell = buildMainScreen();
|
||||
const { refs } = mainShell;
|
||||
applyPanelVisibility(refs);
|
||||
}
|
||||
currentScreen = wantScreen;
|
||||
currentLocale = state.settings.language;
|
||||
} else if (welcome) {
|
||||
updateWelcome(welcomeEl);
|
||||
} else {
|
||||
const { refs } = mainShell;
|
||||
updateToolbarIfNeeded(refs.toolbar);
|
||||
updateSidebarIfNeeded(refs.sidebar);
|
||||
renderTabBar(refs.tabbar);
|
||||
updatePanes(refs.panes);
|
||||
updateQueuePanelIfNeeded(refs.queue);
|
||||
updateStatusBarIfNeeded(refs.statusbar);
|
||||
applyPanelVisibility(refs);
|
||||
}
|
||||
|
||||
renderConnectDialog();
|
||||
renderHistoryPopup();
|
||||
renderOpDialogs();
|
||||
renderSettingsDialog();
|
||||
}
|
||||
|
||||
// Settings → Appearance toggles — animated via CSS transitions.
|
||||
function applyPanelVisibility(refs) {
|
||||
refs.toolbar.classList.toggle("hidden", !state.settings.showToolbar);
|
||||
refs.sidebar.classList.toggle("hidden", !state.settings.showSidebar);
|
||||
refs.statusbar.classList.toggle("hidden", !state.settings.showStatusBar);
|
||||
refs.queue.classList.toggle("hidden", !state.settings.showQueuePanel);
|
||||
|
||||
// Round main-content corners when adjacent panels are hidden
|
||||
const mc = refs.panes.closest(".main-content");
|
||||
if (mc) {
|
||||
mc.classList.toggle("no-sidebar", !state.settings.showSidebar);
|
||||
mc.classList.toggle("no-toolbar", !state.settings.showToolbar);
|
||||
mc.classList.toggle("no-status", !state.settings.showStatusBar && !state.settings.showQueuePanel);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(render);
|
||||
|
||||
// Esc closes the topmost open overlay: op dialog > connect dialog > settings > history popover.
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (state.dialog) {
|
||||
closeDialog();
|
||||
} else if (state.showConnectDialog && !state.connectLoading) {
|
||||
state.showConnectDialog = false;
|
||||
notify();
|
||||
} else if (state.showSettingsDialog) {
|
||||
state.showSettingsDialog = false;
|
||||
notify();
|
||||
} else if (state.showHistoryPopup) {
|
||||
state.showHistoryPopup = false;
|
||||
notify();
|
||||
}
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
initPlatform(); // stamps data-platform/-desktop on <html>; async part fills in later
|
||||
initShortcuts();
|
||||
render(); // paint the welcome screen immediately, don't wait on any IPC
|
||||
|
||||
const [history, tasks, sites] = await Promise.allSettled([
|
||||
ipc.listHistory(),
|
||||
ipc.listTasks(),
|
||||
ipc.listSites(),
|
||||
]);
|
||||
if (history.status === "fulfilled") state.history = history.value;
|
||||
if (tasks.status === "fulfilled") state.queueTasks = tasks.value;
|
||||
if (sites.status === "fulfilled") state.sites = sites.value;
|
||||
notify();
|
||||
|
||||
await loadSettings();
|
||||
notify();
|
||||
|
||||
await initLocal();
|
||||
|
||||
ipc.onTransferProgress(({ id, transferredBytes, speed, etaSecs }) => {
|
||||
const task = state.queueTasks.find((tk) => tk.id === id);
|
||||
if (task) {
|
||||
task.transferredBytes = transferredBytes;
|
||||
task.speed = speed;
|
||||
task.etaSecs = etaSecs;
|
||||
notify();
|
||||
}
|
||||
});
|
||||
|
||||
ipc.onTransferStateChanged(({ id, state: newState }) => {
|
||||
const task = state.queueTasks.find((tk) => tk.id === id);
|
||||
if (task) {
|
||||
task.state = newState;
|
||||
notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
53
frontend/js/platform.js
Normal file
53
frontend/js/platform.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Platform detection + per-OS conventions, applied once at boot.
|
||||
//
|
||||
// The webview UA is enough to tell mac/windows/linux apart, but on Linux the
|
||||
// desktop environment (KDE vs GNOME vs Hyprland/sway) is only visible to the
|
||||
// backend process — initPlatform() asks it via the platform_info command and
|
||||
// stamps everything on <html> as data attributes so CSS can adapt:
|
||||
// <html data-platform="linux" data-desktop="kde" data-session="wayland">
|
||||
import * as ipc from "./ipc.js";
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
export const isMac = /Mac/.test(ua);
|
||||
export const isWindows = /Windows/.test(ua);
|
||||
export const isLinux = !isMac && !isWindows;
|
||||
export const platform = isMac ? "mac" : isWindows ? "windows" : "linux";
|
||||
|
||||
/** Which KeyboardEvent property is the primary shortcut modifier here. */
|
||||
export const modProp = isMac ? "metaKey" : "ctrlKey";
|
||||
|
||||
/** Human-readable shortcut, following each platform's convention:
|
||||
* mac "⌘N" / "⇧⌘N", elsewhere "Ctrl+N" / "Ctrl+Shift+N". */
|
||||
export function shortcutLabel(key, { shift = false, alt = false } = {}) {
|
||||
if (isMac) return `${alt ? "⌥" : ""}${shift ? "⇧" : ""}⌘${key}`;
|
||||
const parts = ["Ctrl"];
|
||||
if (shift) parts.push("Shift");
|
||||
if (alt) parts.push("Alt");
|
||||
parts.push(key);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
|
||||
/** True once initPlatform() resolves; Linux tiling compositors (Hyprland,
|
||||
* sway, …) get data-tiling="true" so CSS can drop window-chrome affordances
|
||||
* that assume floating windows. */
|
||||
const TILING_DESKTOPS = ["hyprland", "sway", "i3", "river", "niri"];
|
||||
|
||||
export async function initPlatform() {
|
||||
const root = document.documentElement;
|
||||
root.dataset.platform = platform;
|
||||
|
||||
try {
|
||||
const info = await ipc.platformInfo();
|
||||
if (info.desktop) {
|
||||
root.dataset.desktop = info.desktop;
|
||||
if (TILING_DESKTOPS.some((d) => info.desktop.includes(d))) {
|
||||
root.dataset.tiling = "true";
|
||||
}
|
||||
}
|
||||
if (info.session) root.dataset.session = info.session;
|
||||
} catch {
|
||||
/* backend command unavailable (e.g. plain-browser dev) — UA data is enough */
|
||||
}
|
||||
}
|
||||
141
frontend/js/settingsActions.js
Normal file
141
frontend/js/settingsActions.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Settings persistence + actions -- mirrors src/ui/app/settings/{prefs,
|
||||
// connections}.rs. Prefs are stored through the generic get_pref/set_pref
|
||||
// string k/v commands (booleans/numbers round-tripped as strings); panel
|
||||
// visibility stays session-only, matching the old AppState (never had a
|
||||
// KEY_ constant / db-backed default of its own).
|
||||
import { state, notify } from "./store.js";
|
||||
import * as ipc from "./ipc.js";
|
||||
import { setLocale, availableLocales } from "./i18n.js";
|
||||
|
||||
const KEY_CONFIRM_DELETE = "confirm_before_delete";
|
||||
const KEY_DEFAULT_LOCAL_FOLDER = "default_local_folder";
|
||||
const KEY_AUTO_CLEAR_SECS = "auto_clear_completed_secs";
|
||||
const KEY_MAX_CONCURRENT = "max_concurrent_transfers";
|
||||
const KEY_THEME = "ui_theme";
|
||||
const KEY_LANGUAGE = "language";
|
||||
|
||||
export async function loadSettings() {
|
||||
const [confirmDelete, defaultFolder, autoClear, maxConcurrent, theme, lang] = await Promise.all([
|
||||
ipc.getPref(KEY_CONFIRM_DELETE),
|
||||
ipc.getPref(KEY_DEFAULT_LOCAL_FOLDER),
|
||||
ipc.getPref(KEY_AUTO_CLEAR_SECS),
|
||||
ipc.getPref(KEY_MAX_CONCURRENT),
|
||||
ipc.getPref(KEY_THEME),
|
||||
ipc.getPref(KEY_LANGUAGE),
|
||||
]);
|
||||
if (confirmDelete !== null) state.settings.confirmBeforeDelete = confirmDelete === "true";
|
||||
if (defaultFolder) state.settings.defaultLocalFolder = defaultFolder;
|
||||
if (autoClear !== null) state.settings.autoClearCompletedSecs = Number(autoClear);
|
||||
if (maxConcurrent !== null) {
|
||||
state.settings.maxConcurrent = Number(maxConcurrent);
|
||||
await ipc.setMaxConcurrent(state.settings.maxConcurrent);
|
||||
}
|
||||
if (theme) {
|
||||
state.settings.theme = theme;
|
||||
applyTheme(theme);
|
||||
}
|
||||
if (lang && availableLocales().includes(lang)) {
|
||||
state.settings.language = lang;
|
||||
setLocale(lang);
|
||||
}
|
||||
}
|
||||
|
||||
const themeTransitionMs = 300;
|
||||
let themeTimeout = null;
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
const html = document.documentElement;
|
||||
// Add a class to enable CSS transitions during theme switch
|
||||
html.classList.add("theme-transitioning");
|
||||
html.setAttribute("data-theme", themeId);
|
||||
clearTimeout(themeTimeout);
|
||||
themeTimeout = setTimeout(() => {
|
||||
html.classList.remove("theme-transitioning");
|
||||
}, themeTransitionMs);
|
||||
}
|
||||
|
||||
export function setTheme(themeId) {
|
||||
state.settings.theme = themeId;
|
||||
applyTheme(themeId);
|
||||
ipc.setPref(KEY_THEME, themeId);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setLanguage(locale) {
|
||||
state.settings.language = locale;
|
||||
setLocale(locale);
|
||||
ipc.setPref(KEY_LANGUAGE, locale);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setConfirmBeforeDelete(value) {
|
||||
state.settings.confirmBeforeDelete = value;
|
||||
ipc.setPref(KEY_CONFIRM_DELETE, String(value));
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function useCurrentFolderAsDefault() {
|
||||
state.settings.defaultLocalFolder = state.local.path;
|
||||
await ipc.setPref(KEY_DEFAULT_LOCAL_FOLDER, state.local.path);
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function resetDefaultFolder() {
|
||||
state.settings.defaultLocalFolder = "";
|
||||
await ipc.setPref(KEY_DEFAULT_LOCAL_FOLDER, "");
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setMaxConcurrent(n) {
|
||||
state.settings.maxConcurrent = n;
|
||||
ipc.setMaxConcurrent(n);
|
||||
ipc.setPref(KEY_MAX_CONCURRENT, String(n));
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setAutoClearCompletedSecs(secs) {
|
||||
state.settings.autoClearCompletedSecs = secs;
|
||||
ipc.setPref(KEY_AUTO_CLEAR_SECS, String(secs));
|
||||
notify();
|
||||
}
|
||||
|
||||
export function togglePanel(key) {
|
||||
state.settings[key] = !state.settings[key];
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function clearHistory() {
|
||||
await ipc.clearHistory();
|
||||
state.history = [];
|
||||
state.statusMessage = "History cleared";
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function deleteSite(id) {
|
||||
const site = state.sites.find((s) => s.id === id);
|
||||
await ipc.deleteSite(id);
|
||||
await ipc.deletePassword(id);
|
||||
state.sites = state.sites.filter((s) => s.id !== id);
|
||||
if (site) state.statusMessage = `Removed ${site.name}`;
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function forgetPassword(siteId) {
|
||||
await ipc.deletePassword(siteId);
|
||||
state.statusMessage = "Password forgotten";
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function forgetAllPasswords() {
|
||||
await Promise.all([
|
||||
...state.sites.map((s) => ipc.deletePassword(s.id)),
|
||||
...state.history.map((h) => ipc.deletePassword(h.connId)),
|
||||
]);
|
||||
state.statusMessage = "All saved passwords forgotten";
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function revealDatabase() {
|
||||
const dir = await ipc.appDataDir();
|
||||
await ipc.localOpen(dir);
|
||||
}
|
||||
134
frontend/js/shortcuts.js
Normal file
134
frontend/js/shortcuts.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Global keyboard shortcuts, following each platform's conventions:
|
||||
// the primary modifier is ⌘ on macOS and Ctrl elsewhere (see platform.js),
|
||||
// and mac-specific idioms (⌘⌫ delete, Enter rename, ⌘↑ go up) are added on
|
||||
// top of the cross-platform set (F2, F5, Delete, Alt+↑).
|
||||
//
|
||||
// Esc is NOT handled here — main.js owns the "close topmost overlay" logic.
|
||||
import { state, notify, activeTab, isWelcome } from "./store.js";
|
||||
import { modProp, isMac } from "./platform.js";
|
||||
import { openNew } from "./components/connectDialog.js";
|
||||
import { openSettings } from "./components/settingsDialog.js";
|
||||
import { openMkdir, openDelete, openRename } from "./components/opDialogs.js";
|
||||
import {
|
||||
refreshLocal,
|
||||
refreshRemote,
|
||||
navigateLocal,
|
||||
navigateRemote,
|
||||
parentPath,
|
||||
closeTab,
|
||||
} from "./fsActions.js";
|
||||
|
||||
function isTyping(e) {
|
||||
const el = e.target;
|
||||
return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
||||
}
|
||||
|
||||
function overlayOpen() {
|
||||
return Boolean(state.dialog || state.showConnectDialog || state.showSettingsDialog);
|
||||
}
|
||||
|
||||
function currentSelection() {
|
||||
if (state.activePane === "remote") return activeTab()?.remoteSelected ?? [];
|
||||
return state.local.selected;
|
||||
}
|
||||
|
||||
function withOpTarget(fn) {
|
||||
state.opTarget = state.activePane;
|
||||
fn();
|
||||
}
|
||||
|
||||
export function initShortcuts() {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
const mod = e[modProp];
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
|
||||
// App-level — work everywhere, including the welcome screen.
|
||||
if (mod && !e.shiftKey && !e.altKey && !overlayOpen()) {
|
||||
if (key === "n") {
|
||||
e.preventDefault();
|
||||
openNew();
|
||||
return;
|
||||
}
|
||||
if (key === ",") {
|
||||
e.preventDefault();
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTyping(e) || overlayOpen() || isWelcome()) return;
|
||||
|
||||
const tab = activeTab();
|
||||
const remote = state.activePane === "remote";
|
||||
|
||||
// Mod+1…9 — switch tabs (1 = Local, 2… = remote tabs in order).
|
||||
if (mod && key >= "1" && key <= "9") {
|
||||
e.preventDefault();
|
||||
const n = Number(key);
|
||||
if (n === 1) {
|
||||
state.activeTabId = null;
|
||||
state.activePane = "local";
|
||||
notify();
|
||||
} else if (state.tabs[n - 2]) {
|
||||
state.activeTabId = state.tabs[n - 2].id;
|
||||
state.activePane = "remote";
|
||||
notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mod+W — close the displayed remote tab (Local can't be closed).
|
||||
if (mod && key === "w" && state.activeTabId) {
|
||||
e.preventDefault();
|
||||
closeTab(state.activeTabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mod+Shift+N — new folder in the active pane.
|
||||
if (mod && e.shiftKey && key === "n") {
|
||||
e.preventDefault();
|
||||
withOpTarget(openMkdir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mod+R / F5 — refresh the active pane.
|
||||
if ((mod && key === "r") || key === "F5") {
|
||||
e.preventDefault();
|
||||
if (remote && tab) refreshRemote(tab);
|
||||
else refreshLocal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Go up a directory: Alt+↑ everywhere, ⌘↑ on mac, bare Backspace.
|
||||
const goUp =
|
||||
(e.altKey && key === "ArrowUp") ||
|
||||
(isMac && mod && key === "ArrowUp") ||
|
||||
(key === "Backspace" && !mod && !e.altKey);
|
||||
if (goUp) {
|
||||
e.preventDefault();
|
||||
if (remote && tab) navigateRemote(tab, parentPath(tab.remotePath));
|
||||
else navigateLocal(parentPath(state.local.path));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rename: F2, plus Enter on mac (Finder convention).
|
||||
if (key === "F2" || (isMac && key === "Enter" && !mod)) {
|
||||
const names = currentSelection();
|
||||
if (names.length === 1) {
|
||||
e.preventDefault();
|
||||
withOpTarget(() => openRename(names[0]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete: Delete key everywhere, ⌘⌫ on mac.
|
||||
if (key === "Delete" || (isMac && mod && key === "Backspace")) {
|
||||
const names = currentSelection();
|
||||
if (names.length > 0) {
|
||||
e.preventDefault();
|
||||
withOpTarget(() => openDelete(names));
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
107
frontend/js/store.js
Normal file
107
frontend/js/store.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Single mutable state object + a plain pub/sub. No framework: components
|
||||
// mutate `state` directly (or via the small helpers below) and then call
|
||||
// notify() — every subscriber re-renders its own slice of the DOM from
|
||||
// scratch. That's cheap at file-manager list sizes and keeps the mental
|
||||
// model identical to the old egui immediate-mode AppState.
|
||||
export const state = {
|
||||
// Local pane
|
||||
local: {
|
||||
path: "",
|
||||
entries: [],
|
||||
selected: [], // names of selected entries, in no particular order
|
||||
selectAnchor: null, // name shift-click range selection extends from
|
||||
sort: { col: "name", dir: "asc" },
|
||||
},
|
||||
|
||||
// One entry per open remote connection tab.
|
||||
// { id, label, params, status, remotePath, remoteEntries, remoteSelected,
|
||||
// remoteSelectAnchor, loading, sort: { col, dir } }
|
||||
tabs: [],
|
||||
activeTabId: null, // null = Local pane currently displayed, otherwise the displayed remote tab's id
|
||||
// The remote connection toolbar actions (Upload, refresh, New Folder, ...)
|
||||
// target. Unlike activeTabId this is NOT cleared when the Local pane is
|
||||
// displayed -- switching to Local to pick a file to upload shouldn't lose
|
||||
// track of which server it's going to, mirroring the old egui AppState
|
||||
// where `active_tab` persisted across the Local dock tab gaining focus.
|
||||
lastRemoteTabId: null,
|
||||
activePane: "local", // 'local' | 'remote' — who last received a click (toolbar targets this)
|
||||
|
||||
// Split-pane layout. Each pane slot holds a tabId (null = Local, otherwise
|
||||
// a remote connection id). The user can rearrange panes by dragging tabs.
|
||||
layout: {
|
||||
dir: "row", // 'row' | 'column' — direction of the split
|
||||
panes: [
|
||||
{ id: "pane-0", tabId: null }, // null = Local
|
||||
],
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
quickAccess: [
|
||||
{ name: "Home", path: "" },
|
||||
{ name: "Desktop", path: "" },
|
||||
{ name: "Documents", path: "" },
|
||||
{ name: "Downloads", path: "" },
|
||||
{ name: "Pictures", path: "" },
|
||||
{ name: "Music", path: "" },
|
||||
{ name: "Videos", path: "" },
|
||||
],
|
||||
bookmarks: [],
|
||||
},
|
||||
|
||||
history: [],
|
||||
sites: [],
|
||||
|
||||
queueTasks: [],
|
||||
showQueue: false,
|
||||
|
||||
statusMessage: "",
|
||||
|
||||
showConnectDialog: false,
|
||||
connectLoading: false,
|
||||
connectError: "",
|
||||
|
||||
showHistoryPopup: false,
|
||||
|
||||
opTarget: "local", // which side New Folder / Rename / Delete apply to
|
||||
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
|
||||
|
||||
showSettingsDialog: false,
|
||||
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
|
||||
// preferences -- see js/components/settingsDialog.js.
|
||||
settings: {
|
||||
language: "en",
|
||||
theme: "ember",
|
||||
confirmBeforeDelete: true,
|
||||
defaultLocalFolder: "",
|
||||
autoClearCompletedSecs: 0,
|
||||
maxConcurrent: 2,
|
||||
showToolbar: true,
|
||||
showSidebar: true,
|
||||
showStatusBar: true,
|
||||
showQueuePanel: true,
|
||||
},
|
||||
};
|
||||
|
||||
const listeners = new Set();
|
||||
|
||||
export function notify() {
|
||||
listeners.forEach((fn) => fn(state));
|
||||
}
|
||||
|
||||
export function subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function activeTab() {
|
||||
return state.tabs.find((t) => t.id === state.lastRemoteTabId) ?? null;
|
||||
}
|
||||
|
||||
export function isWelcome() {
|
||||
return state.tabs.length === 0;
|
||||
}
|
||||
|
||||
export function setStatus(message) {
|
||||
state.statusMessage = message;
|
||||
notify();
|
||||
}
|
||||
|
|
@ -1,22 +1,24 @@
|
|||
[package]
|
||||
name = "loflum"
|
||||
name = "wherry"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[lib]
|
||||
name = "loflum_lib"
|
||||
name = "wherry_lib"
|
||||
path = "lib.rs"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "loflum"
|
||||
name = "wherry"
|
||||
path = "main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.29", features = ["default"] }
|
||||
egui = "0.29"
|
||||
egui_extras = { version = "0.29", features = ["svg", "image"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
@ -24,7 +26,6 @@ anyhow = "1"
|
|||
thiserror = "1"
|
||||
async-trait = "0.1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
keyring = "2"
|
||||
suppaftp = { version = "6", features = ["async", "async-native-tls"] }
|
||||
ssh2 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
|
@ -33,8 +34,4 @@ tracing = "0.1"
|
|||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
futures-lite = "2"
|
||||
image = "0.25"
|
||||
muda = "0.19.3"
|
||||
egui_dock = "0.14"
|
||||
rfd = "0.15"
|
||||
|
||||
|
|
|
|||
3
src/build.rs
Normal file
3
src/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
src/capabilities/default.json
Normal file
7
src/capabilities/default.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the main Wherry window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:default"]
|
||||
}
|
||||
385
src/commands.rs
Normal file
385
src/commands.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
//! Tauri command surface — thin wrappers over domain/fs/protocols/storage/transfer.
|
||||
//! See docs/TAURI_BACKEND_API.md for the design this implements.
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::domain::site::Site;
|
||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
|
||||
use crate::storage::db::{self, HistoryRow};
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
pub registry: Arc<RemoteRegistry>,
|
||||
pub queue: TransferQueue,
|
||||
pub max_concurrent: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
// --- Connections ---------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Result<String, String> {
|
||||
let registry = state.registry.clone();
|
||||
let db = state.db.clone();
|
||||
|
||||
// Пароль приходит с фронтенда и хранится в БД (в sites.password).
|
||||
// Для истории используем канонический conn_id, чтобы повторные
|
||||
// подключения не плодили записи.
|
||||
let canonical_id = db
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|conn| {
|
||||
db::find_history_conn_id(&conn, ¶ms.host, params.port, ¶ms.username)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_else(|| params.id.clone());
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else if let Some(password) = ¶ms.password {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, password)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
let password = params
|
||||
.password
|
||||
.clone()
|
||||
.ok_or("Password is required for FTP connections.")?;
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs);
|
||||
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = db::add_history_entry(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&canonical_id,
|
||||
¶ms.protocol,
|
||||
params.key_path.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(params.id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
|
||||
state.registry.remove(&connection_id);
|
||||
}
|
||||
|
||||
// --- Remote file ops -------------------------------------------------------
|
||||
|
||||
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, String> {
|
||||
state
|
||||
.registry
|
||||
.get(connection_id)
|
||||
.ok_or_else(|| "connection not found".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_list(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
get_remote(&state, &connection_id)?
|
||||
.list(&path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_stat(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<FileEntry, String> {
|
||||
get_remote(&state, &connection_id)?
|
||||
.stat(&path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_mkdir(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
get_remote(&state, &connection_id)?
|
||||
.mkdir(&path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_rename(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> Result<(), String> {
|
||||
get_remote(&state, &connection_id)?
|
||||
.rename(&from, &to)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_delete(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
get_remote(&state, &connection_id)?
|
||||
.delete(&path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// --- Local file ops --------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_list(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
crate::fs::local::list(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_home_dir() -> String {
|
||||
crate::fs::local::home_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_mkdir(path: String) -> Result<(), String> {
|
||||
crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_rename(from: String, to: String) -> Result<(), String> {
|
||||
crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_delete(path: String) -> Result<(), String> {
|
||||
crate::fs::local::delete(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_open(path: String) -> Result<(), String> {
|
||||
crate::fs::local::open(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), String> {
|
||||
crate::fs::local::move_into(&src_path, &dest_dir).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// --- Transfers --------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn enqueue_transfer(
|
||||
state: State<'_, AppState>,
|
||||
kind: TransferKind,
|
||||
connection_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
file_name: String,
|
||||
total_bytes: u64,
|
||||
) -> String {
|
||||
let task = TransferTask::new(kind, connection_id, local_path, remote_path, file_name, total_bytes);
|
||||
let id = task.id.clone();
|
||||
state.queue.push(task);
|
||||
id
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_tasks(state: State<'_, AppState>) -> Vec<TransferTask> {
|
||||
state.queue.all()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_task(state: State<'_, AppState>, id: String) {
|
||||
state.queue.update_state(&id, TaskState::Paused);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn resume_task(state: State<'_, AppState>, id: String) {
|
||||
state.queue.update_state(&id, TaskState::Queued);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cancel_task(state: State<'_, AppState>, id: String) {
|
||||
state.queue.update_state(&id, TaskState::Cancelled);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_task(state: State<'_, AppState>, id: String) {
|
||||
state.queue.remove(&id);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
|
||||
state.max_concurrent.store(n.max(1), Ordering::Relaxed);
|
||||
if let Ok(conn) = state.db.lock() {
|
||||
db::set_u32(&conn, "max_concurrent_transfers", n.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sites / bookmarks / history / settings ---------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::get_sites(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::save_site(&conn, &site).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::delete_site(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::get_bookmarks(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_bookmark(state: State<'_, AppState>, name: String, path: String) -> Result<i64, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::add_bookmark(&conn, &name, &path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::remove_bookmark(&conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::get_history(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_history(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::clear_history(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn find_history_conn_id(
|
||||
state: State<'_, AppState>,
|
||||
host: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::find_history_conn_id(&conn, &host, port, &username).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
Ok(db::get_setting(&conn, &key))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
db::set_setting(&conn, &key, &value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
|
||||
#[tauri::command]
|
||||
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE sites SET password = NULL WHERE id = ?1",
|
||||
rusqlite::params![site_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The folder holding the sqlite db (Settings → About "reveal in folder").
|
||||
#[tauri::command]
|
||||
pub fn app_data_dir() -> String {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("wherry")
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformInfo {
|
||||
pub os: &'static str,
|
||||
/// Linux only: XDG_CURRENT_DESKTOP, lowercased ("kde", "gnome", "hyprland", …).
|
||||
pub desktop: Option<String>,
|
||||
/// Linux only: "wayland" | "x11".
|
||||
pub session: Option<String>,
|
||||
}
|
||||
|
||||
/// Из webview на Linux не различить KDE/GNOME/Hyprland — окружение видно
|
||||
/// только процессу; фронтенд вешает результат как data-атрибуты на <html>.
|
||||
#[tauri::command]
|
||||
pub fn platform_info() -> PlatformInfo {
|
||||
let os = std::env::consts::OS;
|
||||
let (desktop, session) = if os == "linux" {
|
||||
(
|
||||
std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.ok()
|
||||
.map(|d| d.to_lowercase()),
|
||||
std::env::var("XDG_SESSION_TYPE").ok(),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
PlatformInfo { os, desktop, session }
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub struct Site {
|
|||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: Option<String>,
|
||||
pub key_path: Option<String>,
|
||||
pub folder: Option<String>,
|
||||
pub note: Option<String>,
|
||||
|
|
|
|||
1
src/gen/schemas/acl-manifests.json
Normal file
1
src/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src/gen/schemas/capabilities.json
Normal file
1
src/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"default":{"identifier":"default","description":"Capabilities for the main Wherry window","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||
2358
src/gen/schemas/desktop-schema.json
Normal file
2358
src/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
2358
src/gen/schemas/macOS-schema.json
Normal file
2358
src/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -213,18 +213,18 @@ pub const ENTRIES: &[Entry] = &[
|
|||
(
|
||||
"settings.general.startup_folder_hint",
|
||||
[
|
||||
"Local pane opens here when LoFlum starts.",
|
||||
"Локальная панель будет открываться здесь при запуске LoFlum.",
|
||||
"El panel local se abrirá aquí al iniciar LoFlum.",
|
||||
"Le panneau local s'ouvrira ici au démarrage de LoFlum.",
|
||||
"Der lokale Bereich wird hier geöffnet, wenn LoFlum startet.",
|
||||
"Il pannello locale si aprirà qui all'avvio di LoFlum.",
|
||||
"O painel local abrirá aqui quando o LoFlum iniciar.",
|
||||
"Panel lokalny otworzy się tutaj po uruchomieniu LoFlum.",
|
||||
"LoFlum 启动时,本地面板将在此处打开。",
|
||||
"LoFlum 起動時にローカルパネルがここで開きます。",
|
||||
"LoFlum을 시작하면 로컬 패널이 여기에서 열립니다.",
|
||||
"LoFlum başlatıldığında yerel panel burada açılır.",
|
||||
"Local pane opens here when Wherry starts.",
|
||||
"Локальная панель будет открываться здесь при запуске Wherry.",
|
||||
"El panel local se abrirá aquí al iniciar Wherry.",
|
||||
"Le panneau local s'ouvrira ici au démarrage de Wherry.",
|
||||
"Der lokale Bereich wird hier geöffnet, wenn Wherry startet.",
|
||||
"Il pannello locale si aprirà qui all'avvio di Wherry.",
|
||||
"O painel local abrirá aqui quando o Wherry iniciar.",
|
||||
"Panel lokalny otworzy się tutaj po uruchomieniu Wherry.",
|
||||
"Wherry 启动时,本地面板将在此处打开。",
|
||||
"Wherry 起動時にローカルパネルがここで開きます。",
|
||||
"Wherry을 시작하면 로컬 패널이 여기에서 열립니다.",
|
||||
"Wherry başlatıldığında yerel panel burada açılır.",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
@ -577,9 +577,9 @@ pub const ENTRIES: &[Entry] = &[
|
|||
(
|
||||
"settings.security.hint",
|
||||
[
|
||||
"Passwords for saved connections are stored in your system keychain.",
|
||||
"Пароли для сохранённых подключений хранятся в системной связке ключей.",
|
||||
"Las contraseñas de las conexiones guardadas se almacenan en el llavero del sistema.",
|
||||
"Passwords are stored in Wherry's local database.",
|
||||
"Пароли хранятся в локальной базе данных Wherry.",
|
||||
"Las contraseñas se almacenan en la base de datos local de Wherry.",
|
||||
"Les mots de passe des connexions enregistrées sont stockés dans le trousseau système.",
|
||||
"Passwörter für gespeicherte Verbindungen werden im System-Schlüsselbund gespeichert.",
|
||||
"Le password delle connessioni salvate sono conservate nel portachiavi di sistema.",
|
||||
|
|
@ -775,18 +775,18 @@ pub const ENTRIES: &[Entry] = &[
|
|||
(
|
||||
"settings.about.title",
|
||||
[
|
||||
"About LoFlum",
|
||||
"О LoFlum",
|
||||
"Acerca de LoFlum",
|
||||
"À propos de LoFlum",
|
||||
"Über LoFlum",
|
||||
"Informazioni su LoFlum",
|
||||
"Sobre o LoFlum",
|
||||
"O programie LoFlum",
|
||||
"关于 LoFlum",
|
||||
"LoFlum について",
|
||||
"LoFlum 정보",
|
||||
"LoFlum Hakkında",
|
||||
"About Wherry",
|
||||
"О Wherry",
|
||||
"Acerca de Wherry",
|
||||
"À propos de Wherry",
|
||||
"Über Wherry",
|
||||
"Informazioni su Wherry",
|
||||
"Sobre o Wherry",
|
||||
"O programie Wherry",
|
||||
"关于 Wherry",
|
||||
"Wherry について",
|
||||
"Wherry 정보",
|
||||
"Wherry Hakkında",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
|
|||
BIN
src/icons/128x128.png
Normal file
BIN
src/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/icons/128x128@2x.png
Normal file
BIN
src/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src/icons/32x32.png
Normal file
BIN
src/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 988 B |
BIN
src/icons/icon.icns
Normal file
BIN
src/icons/icon.icns
Normal file
Binary file not shown.
BIN
src/icons/icon.ico
Normal file
BIN
src/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
116
src/lib.rs
116
src/lib.rs
|
|
@ -1,86 +1,100 @@
|
|||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod fs;
|
||||
pub mod i18n;
|
||||
pub mod protocols;
|
||||
pub mod storage;
|
||||
pub mod transfer;
|
||||
pub mod ui;
|
||||
|
||||
use crate::commands::AppState;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::app::FileManagerApp;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
|
||||
const DEFAULT_MAX_CONCURRENT: u32 = 2;
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("loflum")
|
||||
.join("loflum.db");
|
||||
.join("wherry")
|
||||
.join("wherry.db");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let conn = rusqlite::Connection::open(&db_path).expect("failed to open loflum database");
|
||||
let conn = rusqlite::Connection::open(&db_path).expect("failed to open wherry database");
|
||||
storage::db::init_tables(&conn).expect("failed to init db tables");
|
||||
let db = Arc::new(std::sync::Mutex::new(conn));
|
||||
|
||||
let sites = {
|
||||
let max_concurrent = {
|
||||
let c = db.lock().unwrap();
|
||||
storage::db::get_sites(&c).unwrap_or_default()
|
||||
Arc::new(AtomicU32::new(storage::db::get_u32(
|
||||
&c,
|
||||
"max_concurrent_transfers",
|
||||
DEFAULT_MAX_CONCURRENT,
|
||||
)))
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
|
||||
let rt_handle = rt.handle().clone();
|
||||
|
||||
let registry = Arc::new(RemoteRegistry::default());
|
||||
let queue = TransferQueue::default();
|
||||
|
||||
// Реальное значение подтягивается из БД внутри FileManagerApp::new —
|
||||
// здесь только общий на воркер и UI атомик, который Settings → Transfers
|
||||
// меняет на лету.
|
||||
let max_concurrent = Arc::new(AtomicU32::new(2));
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState {
|
||||
db,
|
||||
registry: registry.clone(),
|
||||
queue: queue.clone(),
|
||||
max_concurrent: max_concurrent.clone(),
|
||||
})
|
||||
.setup(move |app| {
|
||||
transfer::worker::spawn_worker(
|
||||
queue.clone(),
|
||||
registry.clone(),
|
||||
rt_handle,
|
||||
tauri::async_runtime::handle().inner().clone(),
|
||||
max_concurrent.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
|
||||
let app = FileManagerApp::new(registry, queue, rt, db, sites, max_concurrent);
|
||||
|
||||
let icon = load_app_icon();
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LoFlum")
|
||||
.with_inner_size([1280.0, 800.0])
|
||||
.with_min_inner_size([900.0, 600.0])
|
||||
.with_icon(icon),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"LoFlum",
|
||||
native_options,
|
||||
Box::new(|_cc| {
|
||||
crate::ui::menu::setup_native_menu();
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
.expect("failed to start LoFlum");
|
||||
}
|
||||
|
||||
fn load_app_icon() -> egui::IconData {
|
||||
let bytes = include_bytes!("ui/icons/app_icon.png");
|
||||
let image = image::load_from_memory(bytes)
|
||||
.expect("failed to decode app icon")
|
||||
.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
egui::IconData {
|
||||
rgba: image.into_raw(),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::connect,
|
||||
commands::disconnect,
|
||||
commands::remote_list,
|
||||
commands::remote_stat,
|
||||
commands::remote_mkdir,
|
||||
commands::remote_rename,
|
||||
commands::remote_delete,
|
||||
commands::local_list,
|
||||
commands::local_home_dir,
|
||||
commands::local_mkdir,
|
||||
commands::local_rename,
|
||||
commands::local_delete,
|
||||
commands::local_open,
|
||||
commands::local_move_into,
|
||||
commands::enqueue_transfer,
|
||||
commands::list_tasks,
|
||||
commands::pause_task,
|
||||
commands::resume_task,
|
||||
commands::cancel_task,
|
||||
commands::remove_task,
|
||||
commands::set_max_concurrent,
|
||||
commands::list_sites,
|
||||
commands::save_site,
|
||||
commands::delete_site,
|
||||
commands::list_bookmarks,
|
||||
commands::add_bookmark,
|
||||
commands::remove_bookmark,
|
||||
commands::list_history,
|
||||
commands::clear_history,
|
||||
commands::find_history_conn_id,
|
||||
commands::get_pref,
|
||||
commands::set_pref,
|
||||
commands::delete_password,
|
||||
commands::app_data_dir,
|
||||
commands::platform_info,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start Wherry");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
loflum_lib::run();
|
||||
wherry_lib::run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Resul
|
|||
fingerprint_hex(session)
|
||||
);
|
||||
known
|
||||
.add(host, key, "loflum", key_type.into())
|
||||
.add(host, key, "wherry", key_type.into())
|
||||
.context("failed to add host key to known_hosts")?;
|
||||
if let Some(ref path) = known_hosts_path()
|
||||
&& let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
|||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT,
|
||||
key_path TEXT,
|
||||
folder TEXT,
|
||||
note TEXT
|
||||
|
|
@ -69,8 +70,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
|||
",
|
||||
)?;
|
||||
|
||||
// Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем,
|
||||
// если их ещё нет (ошибку "duplicate column" просто игнорируем).
|
||||
// Миграции
|
||||
let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'",
|
||||
|
|
@ -80,6 +80,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
|||
"ALTER TABLE connection_history ADD COLUMN key_path TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute("ALTER TABLE sites ADD COLUMN password TEXT", []);
|
||||
// На случай старых записей без уникальности (host,port,username) — оставляем
|
||||
// только самую свежую строку на каждую цель перед созданием индекса.
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use crate::domain::site::Site;
|
|||
|
||||
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name",
|
||||
"SELECT id, name, protocol, host, port, username, password, key_path, folder, note FROM sites ORDER BY name",
|
||||
)?;
|
||||
let sites = stmt
|
||||
.query_map([], |row| {
|
||||
|
|
@ -20,9 +20,10 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
|||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
key_path: row.get(6)?,
|
||||
folder: row.get(7)?,
|
||||
note: row.get(8)?,
|
||||
password: row.get(6)?,
|
||||
key_path: row.get(7)?,
|
||||
folder: row.get(8)?,
|
||||
note: row.get(9)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
|
|
@ -34,8 +35,8 @@ pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
|||
let proto = protocol_to_str(&site.protocol);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sites
|
||||
(id, name, protocol, host, port, username, key_path, folder, note)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
(id, name, protocol, host, port, username, password, key_path, folder, note)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
params![
|
||||
site.id,
|
||||
site.name,
|
||||
|
|
@ -43,6 +44,7 @@ pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
|||
site.host,
|
||||
site.port,
|
||||
site.username,
|
||||
site.password,
|
||||
site.key_path,
|
||||
site.folder,
|
||||
site.note
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use keyring::Entry;
|
||||
|
||||
const SERVICE: &str = "loflum-ftp-client";
|
||||
|
||||
pub fn store_password(site_id: &str, password: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.set_password(password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_password(site_id: &str) -> Result<String> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
Ok(entry.get_password()?)
|
||||
}
|
||||
|
||||
pub fn delete_password(site_id: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.delete_password()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
pub mod db;
|
||||
pub mod keychain;
|
||||
|
|
|
|||
37
src/tauri.conf.json
Normal file
37
src/tauri.conf.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Wherry",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.wherry.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Wherry",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
||||
|
|
@ -11,6 +12,44 @@ use crate::transfer::queue::TransferQueue;
|
|||
|
||||
const POLL_INTERVAL_MS: u64 = 200;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ProgressPayload {
|
||||
id: String,
|
||||
transferred_bytes: u64,
|
||||
speed: u64,
|
||||
eta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct StatePayload {
|
||||
id: String,
|
||||
state: TaskState,
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, queue: &TransferQueue, id: &str) {
|
||||
if let Some(t) = queue.get(id) {
|
||||
let _ = app.emit(
|
||||
"transfer-progress",
|
||||
ProgressPayload {
|
||||
id: t.id,
|
||||
transferred_bytes: t.transferred_bytes,
|
||||
speed: t.speed.unwrap_or(0),
|
||||
eta_secs: t.eta_secs,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_state(app: &AppHandle, id: &str, state: TaskState) {
|
||||
let _ = app.emit(
|
||||
"transfer-state-changed",
|
||||
StatePayload {
|
||||
id: id.to_string(),
|
||||
state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Уменьшает счётчик активных передач при выходе из скоупа — в том числе при
|
||||
/// панике внутри задачи (Drop отрабатывает и во время размотки стека), иначе
|
||||
/// упавшая задача навсегда съедала бы один слот параллелизма.
|
||||
|
|
@ -30,6 +69,7 @@ pub fn spawn_worker(
|
|||
registry: Arc<RemoteRegistry>,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
max_concurrent: Arc<AtomicU32>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
rt_handle.spawn(async move {
|
||||
|
|
@ -44,14 +84,16 @@ pub fn spawn_worker(
|
|||
break;
|
||||
};
|
||||
queue.update_state(&task.id, TaskState::Running);
|
||||
emit_state(&app, &task.id, TaskState::Running);
|
||||
in_flight.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let queue = queue.clone();
|
||||
let registry = registry.clone();
|
||||
let guard_counter = in_flight.clone();
|
||||
let app = app.clone();
|
||||
tokio::spawn(async move {
|
||||
let _guard = InFlightGuard(guard_counter);
|
||||
run_transfer(task, queue, registry).await;
|
||||
run_transfer(task, queue, registry, app).await;
|
||||
});
|
||||
}
|
||||
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||
|
|
@ -60,11 +102,21 @@ pub fn spawn_worker(
|
|||
}
|
||||
|
||||
/// Выполняет одну задачу передачи целиком и записывает итог в очередь.
|
||||
async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<RemoteRegistry>) {
|
||||
async fn run_transfer(
|
||||
task: TransferTask,
|
||||
queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
let fs = match registry.get(&task.connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
queue.update_state(&task.id, TaskState::Failed("connection not found".into()));
|
||||
emit_state(
|
||||
&app,
|
||||
&task.id,
|
||||
TaskState::Failed("connection not found".into()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
@ -73,6 +125,7 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
|
|||
// текущую скорость и реагирует на отмену/паузу, выставленные пользователем.
|
||||
let queue_for_progress = queue.clone();
|
||||
let task_id_for_progress = task.id.clone();
|
||||
let app_for_progress = app.clone();
|
||||
let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default()));
|
||||
let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64)));
|
||||
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> =
|
||||
|
|
@ -100,6 +153,7 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
|
|||
};
|
||||
if throttle.lock().unwrap().should_emit() {
|
||||
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
|
||||
emit_progress(&app_for_progress, &queue_for_progress, &task_id_for_progress);
|
||||
}
|
||||
ProgressAction::Continue
|
||||
}));
|
||||
|
|
@ -119,12 +173,15 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
|
|||
Ok(()) => {
|
||||
queue.update_state(&task.id, TaskState::Completed);
|
||||
queue.update_progress(&task.id, task.total_bytes, 0);
|
||||
emit_progress(&app, &queue, &task.id);
|
||||
emit_state(&app, &task.id, TaskState::Completed);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg == "cancelled" {
|
||||
queue.update_state(&task.id, TaskState::Cancelled);
|
||||
queue.remove(&task.id);
|
||||
emit_state(&app, &task.id, TaskState::Cancelled);
|
||||
} else if msg == "paused" {
|
||||
let transferred = queue
|
||||
.get(&task.id)
|
||||
|
|
@ -132,8 +189,10 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
|
|||
.unwrap_or(0);
|
||||
queue.update_state(&task.id, TaskState::Paused);
|
||||
queue.update_progress(&task.id, transferred, 0);
|
||||
emit_state(&app, &task.id, TaskState::Paused);
|
||||
} else {
|
||||
queue.update_state(&task.id, TaskState::Failed(msg));
|
||||
queue.update_state(&task.id, TaskState::Failed(msg.clone()));
|
||||
emit_state(&app, &task.id, TaskState::Failed(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
//! Модальные диалоги ФС (New Folder / Delete / Rename) — общий каркас модалки
|
||||
//! (окно/кнопки) вынесен в [`crate::ui::widgets::modal`].
|
||||
use egui::{Context, RichText, Ui};
|
||||
|
||||
use super::FileManagerApp;
|
||||
// Алиас `tr`: в этом файле `t` — анимационная «открытость» (f32), не перевод.
|
||||
use crate::i18n::{t as tr, tf};
|
||||
use crate::ui::icons;
|
||||
use crate::ui::state::Pane;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::modal::{self, Outcome};
|
||||
use crate::ui::widgets::overlay;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_remote_op_dialogs(&mut self, ctx: &Context) {
|
||||
// Settings → General → "Confirm before deleting" выключен — удаляем
|
||||
// сразу, до расчёта затемнения, чтобы не мелькнуть окном на 1 кадр.
|
||||
if self.state.show_delete_dialog && !self.state.confirm_before_delete {
|
||||
let name = self.state.delete_name.clone();
|
||||
self.dispatch_delete(name);
|
||||
self.state.show_delete_dialog = false;
|
||||
}
|
||||
|
||||
// «Открытость» считается каждый кадр для каждого диалога (см. док к
|
||||
// overlay::openness) — окна дорисовываются, пока доигрывает fade-out.
|
||||
let t_mkdir = overlay::openness(ctx, "mkdir_dlg", self.state.show_mkdir_dialog);
|
||||
let t_delete = overlay::openness(ctx, "delete_dlg", self.state.show_delete_dialog);
|
||||
let t_rename = overlay::openness(ctx, "rename_dlg", self.state.show_rename_dialog);
|
||||
|
||||
let t_any = t_mkdir.max(t_delete).max(t_rename);
|
||||
if t_any > 0.0 {
|
||||
let _ = overlay::dim(ctx, "remote_op_overlay", t_any);
|
||||
}
|
||||
|
||||
if t_mkdir > 0.0 {
|
||||
match text_modal(
|
||||
ctx,
|
||||
"new_folder_dialog",
|
||||
t_mkdir,
|
||||
tr("common.new_folder"),
|
||||
&mut self.state.mkdir_name,
|
||||
) {
|
||||
Outcome::Confirm if !self.state.mkdir_name.is_empty() => {
|
||||
let name = self.state.mkdir_name.clone();
|
||||
self.dispatch_mkdir(name);
|
||||
self.state.show_mkdir_dialog = false;
|
||||
}
|
||||
// Поля не чистим при отмене: открыватели инициализируют их
|
||||
// сами, а очистка здесь заставила бы контент мигать в fade-out.
|
||||
Outcome::Cancel => self.state.show_mkdir_dialog = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if t_delete > 0.0 {
|
||||
let name = self.state.delete_name.clone();
|
||||
match confirm_modal(ctx, "delete_dialog", t_delete, &name) {
|
||||
Outcome::Confirm => {
|
||||
self.dispatch_delete(name);
|
||||
self.state.show_delete_dialog = false;
|
||||
}
|
||||
Outcome::Cancel => self.state.show_delete_dialog = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if t_rename > 0.0 {
|
||||
match text_modal(
|
||||
ctx,
|
||||
"rename_dialog",
|
||||
t_rename,
|
||||
tr("common.rename"),
|
||||
&mut self.state.rename_new_name,
|
||||
) {
|
||||
Outcome::Confirm if !self.state.rename_new_name.is_empty() => {
|
||||
let old_name = self.state.rename_old_name.clone();
|
||||
let new_name = self.state.rename_new_name.clone();
|
||||
self.dispatch_rename(old_name, new_name);
|
||||
self.state.show_rename_dialog = false;
|
||||
}
|
||||
Outcome::Cancel => self.state.show_rename_dialog = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_mkdir(&mut self, name: String) {
|
||||
match self.state.op_target {
|
||||
Pane::Local => self.start_local_mkdir(name),
|
||||
Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_mkdir(idx, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delete(&mut self, name: String) {
|
||||
match self.state.op_target {
|
||||
Pane::Local => self.start_local_delete(name),
|
||||
Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_delete(idx, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_rename(&mut self, old_name: String, new_name: String) {
|
||||
match self.state.op_target {
|
||||
Pane::Local => self.start_local_rename(old_name, new_name),
|
||||
Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_rename(idx, old_name, new_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Модалка с одним текстовым полем (New Folder / Rename): фокус в поле сразу,
|
||||
/// Enter подтверждает.
|
||||
fn text_modal(ctx: &Context, id: &str, t: f32, title: &str, value: &mut String) -> Outcome {
|
||||
let mut outcome = Outcome::Idle;
|
||||
modal::window(ctx, id, t, |ui| {
|
||||
ui.label(RichText::new(title).color(TEXT_PRIMARY).strong());
|
||||
ui.add_space(12.0);
|
||||
let resp = ui.text_edit_singleline(value);
|
||||
if t >= 1.0 {
|
||||
resp.request_focus();
|
||||
}
|
||||
let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.add_space(16.0);
|
||||
let row = modal::ok_cancel_row(ui, false);
|
||||
outcome = if entered { Outcome::Confirm } else { row };
|
||||
});
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Диалог подтверждения удаления (красная кнопка OK).
|
||||
fn confirm_modal(ctx: &Context, id: &str, t: f32, name: &str) -> Outcome {
|
||||
let mut outcome = Outcome::Idle;
|
||||
modal::window(ctx, id, t, |ui: &mut Ui| {
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, icons::Icon::DangerTriangle, 18.0, RED);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(tf("dialogs.confirm_delete_title", &[("{name}", name)]))
|
||||
.color(TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(tr("dialogs.cannot_undo"))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
outcome = modal::ok_cancel_row(ui, true);
|
||||
});
|
||||
outcome
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::site::Site;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::dialogs::connection;
|
||||
use crate::ui::dock::PaneTab;
|
||||
use crate::ui::panels::local_pane;
|
||||
use crate::ui::widgets::overlay;
|
||||
|
||||
mod dialogs;
|
||||
mod ops;
|
||||
mod poll;
|
||||
mod results;
|
||||
mod screens;
|
||||
mod settings;
|
||||
mod visuals;
|
||||
mod welcome;
|
||||
|
||||
pub struct FileManagerApp {
|
||||
pub state: crate::ui::state::AppState,
|
||||
pub registry: Arc<RemoteRegistry>,
|
||||
pub queue: TransferQueue,
|
||||
pub rt: tokio::runtime::Runtime,
|
||||
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
pub sites: Vec<Site>,
|
||||
pub first_frame: bool,
|
||||
pub dock_state: egui_dock::DockState<PaneTab>,
|
||||
/// Момент, когда задача передачи впервые замечена в состоянии Completed —
|
||||
/// нужен для авто-очистки очереди (Settings → Transfers).
|
||||
completed_since: HashMap<String, Instant>,
|
||||
/// Какой экран был в прошлом кадре (welcome/main) + момент переключения —
|
||||
/// для фейда при переходе между экранами. None до первого кадра.
|
||||
prev_is_welcome: Option<bool>,
|
||||
screen_changed_at: f64,
|
||||
}
|
||||
|
||||
impl FileManagerApp {
|
||||
pub fn new(
|
||||
registry: Arc<RemoteRegistry>,
|
||||
queue: TransferQueue,
|
||||
rt: tokio::runtime::Runtime,
|
||||
db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
sites: Vec<Site>,
|
||||
max_concurrent: Arc<AtomicU32>,
|
||||
) -> Self {
|
||||
let prefs = settings::prefs::load(&db);
|
||||
max_concurrent.store(prefs.max_concurrent, Ordering::Relaxed);
|
||||
crate::i18n::set_lang(prefs.language.unwrap_or_else(crate::i18n::detect_system_lang));
|
||||
|
||||
let start_path = if !prefs.default_local_folder.is_empty()
|
||||
&& std::path::Path::new(&prefs.default_local_folder).is_dir()
|
||||
{
|
||||
prefs.default_local_folder.clone()
|
||||
} else {
|
||||
crate::fs::local::home_dir()
|
||||
};
|
||||
|
||||
let mut state = crate::ui::state::AppState {
|
||||
local_path: start_path,
|
||||
confirm_before_delete: prefs.confirm_before_delete,
|
||||
default_local_folder: prefs.default_local_folder,
|
||||
auto_clear_completed_secs: prefs.auto_clear_completed_secs,
|
||||
max_concurrent,
|
||||
..Default::default()
|
||||
};
|
||||
local_pane::refresh_local(&mut state);
|
||||
|
||||
state.reload_history(&db);
|
||||
|
||||
if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) {
|
||||
state.bookmarks = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path })
|
||||
.collect();
|
||||
}
|
||||
|
||||
Self {
|
||||
state,
|
||||
registry,
|
||||
queue,
|
||||
rt,
|
||||
db,
|
||||
sites,
|
||||
first_frame: true,
|
||||
dock_state: egui_dock::DockState::new(vec![PaneTab::Local]),
|
||||
completed_since: HashMap::new(),
|
||||
prev_is_welcome: None,
|
||||
screen_changed_at: f64::NEG_INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
fn active_tab_idx(&self) -> Option<usize> {
|
||||
if self.state.tabs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let idx = self.state.active_tab.min(self.state.tabs.len() - 1);
|
||||
if self.state.tabs[idx].status == ConnectionStatus::Connected {
|
||||
Some(idx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for FileManagerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.first_frame {
|
||||
self.first_frame = false;
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
self.apply_visuals(ctx);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
self.poll_pending();
|
||||
crate::ui::menu::poll_menu_events(&mut self.state);
|
||||
self.state.queue_tasks = self.queue.all();
|
||||
self.sweep_completed_tasks();
|
||||
|
||||
// ── Esc закрывает верхний из открытых диалогов ───────────────────────
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let s = &mut self.state;
|
||||
if s.show_mkdir_dialog {
|
||||
s.show_mkdir_dialog = false;
|
||||
} else if s.show_delete_dialog {
|
||||
s.show_delete_dialog = false;
|
||||
} else if s.show_rename_dialog {
|
||||
s.show_rename_dialog = false;
|
||||
} else if s.show_connect_dialog && !s.connect_loading {
|
||||
s.show_connect_dialog = false;
|
||||
} else if s.show_settings_dialog {
|
||||
s.show_settings_dialog = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Переход welcome ↔ main с фейдом ──────────────────────────────────
|
||||
let is_welcome = self.state.tabs.is_empty();
|
||||
if self.prev_is_welcome != Some(is_welcome) {
|
||||
// На самом первом кадре не анимируем — окно и так только появилось.
|
||||
if self.prev_is_welcome.is_some() {
|
||||
self.screen_changed_at = ctx.input(|i| i.time);
|
||||
}
|
||||
self.prev_is_welcome = Some(is_welcome);
|
||||
}
|
||||
let screen_fade =
|
||||
(((ctx.input(|i| i.time) - self.screen_changed_at) / 0.2).clamp(0.0, 1.0)) as f32;
|
||||
if screen_fade < 1.0 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
if is_welcome {
|
||||
self.render_welcome_screen(ctx, screen_fade);
|
||||
} else {
|
||||
self.render_main_ui(ctx, screen_fade);
|
||||
}
|
||||
|
||||
// «Открытость» диалогов тикает каждый кадр (см. док к overlay::openness),
|
||||
// окна рисуются, пока не доиграл fade-out.
|
||||
// ── Connection dialog ────────────────────────────────────────────────
|
||||
let t_connect = overlay::openness(ctx, "connect_dlg", self.state.show_connect_dialog);
|
||||
if t_connect > 0.0 {
|
||||
connection::render(
|
||||
ctx,
|
||||
&mut self.state,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
t_connect,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Remote operation dialogs ─────────────────────────────────────────
|
||||
self.render_remote_op_dialogs(ctx);
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
let t_settings = overlay::openness(ctx, "settings_dlg", self.state.show_settings_dialog);
|
||||
if t_settings > 0.0 {
|
||||
self.render_settings_dialog(ctx, t_settings);
|
||||
}
|
||||
|
||||
// 200ms repaint для обновления прогресса
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
//! Запуск операций над ФС из диалогов (mkdir/delete/rename), локально и удалённо.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::panels::{local_pane, remote_pane};
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn start_mkdir(&mut self, tab_idx: usize, name: String) {
|
||||
let tab = &self.state.tabs[tab_idx];
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path = tab.remote_path.clone();
|
||||
let registry = self.registry.clone();
|
||||
let path = format!("{}/{}", remote_path.trim_end_matches('/'), name);
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
|
||||
self.rt.handle().spawn(async move {
|
||||
let fs = match registry.get(&connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.mkdir(&path).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
self.state.pending_mkdir_result = Some(result);
|
||||
}
|
||||
|
||||
pub(super) fn start_delete(&mut self, tab_idx: usize, name: String) {
|
||||
let tab = &self.state.tabs[tab_idx];
|
||||
let connection_id = tab.id.clone();
|
||||
let registry = self.registry.clone();
|
||||
|
||||
let entry_path = tab
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.map(|e| e.path.clone());
|
||||
|
||||
if name == ".." {
|
||||
self.state.status_message = "Cannot delete parent entry".into();
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(path) = entry_path else {
|
||||
self.state.status_message = "Selected entry not found".into();
|
||||
return;
|
||||
};
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
|
||||
self.rt.handle().spawn(async move {
|
||||
let fs = match registry.get(&connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.delete(&path).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
self.state.pending_delete_result = Some(result);
|
||||
}
|
||||
|
||||
pub(super) fn start_rename(&mut self, tab_idx: usize, old_name: String, new_name: String) {
|
||||
let tab = &self.state.tabs[tab_idx];
|
||||
let connection_id = tab.id.clone();
|
||||
let registry = self.registry.clone();
|
||||
|
||||
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == old_name) else {
|
||||
self.state.status_message = "Selected entry not found".into();
|
||||
return;
|
||||
};
|
||||
|
||||
let from = entry.path.clone();
|
||||
let parent_dir = remote_pane::remote_parent(&from);
|
||||
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
|
||||
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
|
||||
self.rt.handle().spawn(async move {
|
||||
let fs = match registry.get(&connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.rename(&from, &to).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
self.state.pending_rename_result = Some(result);
|
||||
}
|
||||
|
||||
pub(super) fn start_local_mkdir(&mut self, name: String) {
|
||||
let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name);
|
||||
match crate::fs::local::mkdir(&path) {
|
||||
Ok(()) => {
|
||||
self.state.status_message = "Folder created".into();
|
||||
local_pane::refresh_local(&mut self.state);
|
||||
}
|
||||
Err(e) => self.state.status_message = format!("Create folder failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_local_delete(&mut self, name: String) {
|
||||
if name == ".." {
|
||||
self.state.status_message = "Cannot delete parent entry".into();
|
||||
return;
|
||||
}
|
||||
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else {
|
||||
self.state.status_message = "Selected entry not found".into();
|
||||
return;
|
||||
};
|
||||
match crate::fs::local::delete(&entry.path) {
|
||||
Ok(()) => {
|
||||
self.state.status_message = "Deleted".into();
|
||||
local_pane::refresh_local(&mut self.state);
|
||||
}
|
||||
Err(e) => self.state.status_message = format!("Delete failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_local_rename(&mut self, old_name: String, new_name: String) {
|
||||
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else {
|
||||
self.state.status_message = "Selected entry not found".into();
|
||||
return;
|
||||
};
|
||||
let from = entry.path.clone();
|
||||
let parent_dir = std::path::Path::new(&from)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| self.state.local_path.clone());
|
||||
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
|
||||
match crate::fs::local::rename(&from, &to) {
|
||||
Ok(()) => {
|
||||
self.state.status_message = "Renamed".into();
|
||||
local_pane::refresh_local(&mut self.state);
|
||||
}
|
||||
Err(e) => self.state.status_message = format!("Rename failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
//! Опрос результатов асинхронных операций раз в кадр из `update`.
|
||||
//! Разбор connect/list — в [`super::results`], здесь — диспетчер, флаги действий
|
||||
//! и результаты mkdir/delete/rename.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::dialogs::connection;
|
||||
use crate::ui::panels::remote_pane;
|
||||
|
||||
impl FileManagerApp {
|
||||
/// Убирает завершённые задачи из очереди передач через
|
||||
/// `auto_clear_completed_secs` секунд после завершения (0 — никогда).
|
||||
/// Вызывается каждый кадр, сразу после обновления `state.queue_tasks`.
|
||||
pub(super) fn sweep_completed_tasks(&mut self) {
|
||||
use crate::domain::transfer::TaskState;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let live_ids: HashSet<&str> = self
|
||||
.state
|
||||
.queue_tasks
|
||||
.iter()
|
||||
.map(|t| t.id.as_str())
|
||||
.collect();
|
||||
self.completed_since
|
||||
.retain(|id, _| live_ids.contains(id.as_str()));
|
||||
|
||||
for task in &self.state.queue_tasks {
|
||||
if task.state == TaskState::Completed {
|
||||
self.completed_since.entry(task.id.clone()).or_insert(now);
|
||||
}
|
||||
}
|
||||
|
||||
let secs = self.state.auto_clear_completed_secs;
|
||||
if secs == 0 {
|
||||
return;
|
||||
}
|
||||
let expired: Vec<String> = self
|
||||
.completed_since
|
||||
.iter()
|
||||
.filter(|(_, since)| now.duration_since(**since).as_secs() >= secs as u64)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in expired {
|
||||
self.queue.remove(&id);
|
||||
self.completed_since.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_pending(&mut self) {
|
||||
self.poll_connect();
|
||||
self.poll_remote_list();
|
||||
self.poll_action_flags();
|
||||
self.poll_op_results();
|
||||
}
|
||||
|
||||
// --- Toolbar/history action flags ---
|
||||
fn poll_action_flags(&mut self) {
|
||||
if self.state.pending_refresh {
|
||||
self.state.pending_refresh = false;
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle());
|
||||
}
|
||||
}
|
||||
|
||||
// История: переподключение по клику
|
||||
if let Some(entry) = self.state.pending_history_reconnect.take() {
|
||||
connection::reconnect_from_history(
|
||||
&mut self.state,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
&entry,
|
||||
);
|
||||
}
|
||||
|
||||
// История: «Save» → постоянный Site
|
||||
if let Some(entry) = self.state.pending_history_save.take() {
|
||||
match connection::save_history_as_site(&self.db, &mut self.sites, &entry) {
|
||||
Ok(()) => self.state.status_message = "Saved to sites".into(),
|
||||
Err(e) => self.state.status_message = format!("Save failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- mkdir/delete/rename results ---
|
||||
fn poll_op_results(&mut self) {
|
||||
if let Some(res) = take_ready(&mut self.state.pending_mkdir_result) {
|
||||
self.on_op_done(res, "Folder created", "Create folder failed");
|
||||
}
|
||||
if let Some(res) = take_ready(&mut self.state.pending_delete_result) {
|
||||
self.on_op_done(res, "Deleted", "Delete failed");
|
||||
}
|
||||
if let Some(res) = take_ready(&mut self.state.pending_rename_result) {
|
||||
self.on_op_done(res, "Renamed", "Rename failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Общая обработка результата операции над ФС: статус + refresh списка при успехе.
|
||||
fn on_op_done(&mut self, res: Result<(), String>, ok_msg: &str, err_prefix: &str) {
|
||||
match res {
|
||||
Ok(()) => {
|
||||
self.state.status_message = ok_msg.into();
|
||||
self.state.mkdir_name.clear();
|
||||
self.state.delete_name.clear();
|
||||
self.state.rename_old_name.clear();
|
||||
self.state.rename_new_name.clear();
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
remote_pane::trigger_list(
|
||||
&mut self.state,
|
||||
idx,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => self.state.status_message = format!("{}: {}", err_prefix, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Забирает готовый результат из отложенной операции (и очищает слот), если он есть.
|
||||
type PendingResult = Arc<std::sync::Mutex<Option<Result<(), String>>>>;
|
||||
fn take_ready(slot: &mut Option<PendingResult>) -> Option<Result<(), String>> {
|
||||
let opt = slot.take();
|
||||
let res = opt.as_ref()?;
|
||||
let mut guard = res.lock().unwrap();
|
||||
if let Some(r) = guard.take() {
|
||||
drop(guard);
|
||||
Some(r)
|
||||
} else {
|
||||
drop(guard);
|
||||
*slot = opt;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
//! Разбор результатов подключения и листинга удалённой директории.
|
||||
use super::FileManagerApp;
|
||||
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::dock::PaneTab;
|
||||
use crate::ui::panels::remote_pane;
|
||||
use crate::ui::state::ConnectionTab;
|
||||
|
||||
impl FileManagerApp {
|
||||
// --- Connect result ---
|
||||
pub(super) fn poll_connect(&mut self) {
|
||||
let opt = self.state.pending_connect.take();
|
||||
let Some(pc) = opt.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let mut guard = pc.result.lock().unwrap();
|
||||
let Some(res) = guard.take() else {
|
||||
drop(guard);
|
||||
self.state.pending_connect = opt;
|
||||
return;
|
||||
};
|
||||
drop(guard);
|
||||
|
||||
match res {
|
||||
Ok((params, entries)) => {
|
||||
let path = "/".to_string();
|
||||
let mut list = vec![FileEntry {
|
||||
name: "..".into(),
|
||||
path: path.clone(),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}];
|
||||
list.extend(entries);
|
||||
|
||||
let tab = ConnectionTab {
|
||||
id: params.id.clone(),
|
||||
label: params.label.clone(),
|
||||
params: params.clone(),
|
||||
status: ConnectionStatus::Connected,
|
||||
remote_path: path,
|
||||
remote_entries: list,
|
||||
remote_selected: None,
|
||||
loading: false,
|
||||
};
|
||||
self.state.tabs.push(tab);
|
||||
self.state.active_tab = self.state.tabs.len() - 1;
|
||||
self.dock_state
|
||||
.push_to_focused_leaf(PaneTab::Remote(params.id.clone()));
|
||||
self.state.status_message = format!("Connected to {}", params.host);
|
||||
self.state.connect_loading = false;
|
||||
self.state.show_connect_dialog = false;
|
||||
|
||||
self.persist_connection(¶ms);
|
||||
self.state.reload_history(&self.db);
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.status_message = format!("Connection failed: {}", e);
|
||||
self.state.connect_error = e;
|
||||
self.state.connect_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Один и тот же host/port/username всегда должен использовать один conn_id —
|
||||
/// под ним лежит пароль в keychain, иначе повторное подключение через
|
||||
/// «New Connection» развело бы историю и keychain.
|
||||
fn persist_connection(&self, params: &ConnectionParams) {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let canonical_id = crate::storage::db::find_history_conn_id(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| params.id.clone());
|
||||
|
||||
if let Some(password) = ¶ms.password {
|
||||
let _ = crate::storage::keychain::store_password(&canonical_id, password);
|
||||
}
|
||||
|
||||
let _ = crate::storage::db::add_history_entry(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&canonical_id,
|
||||
¶ms.protocol,
|
||||
params.key_path.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Remote list results ---
|
||||
pub(super) fn poll_remote_list(&mut self) {
|
||||
let mut done: Vec<usize> = Vec::new();
|
||||
for (i, pending) in self.state.pending_remote_list.iter().enumerate() {
|
||||
let mut guard = pending.result.lock().unwrap();
|
||||
if let Some(res) = guard.take() {
|
||||
let tab_idx = pending.tab_idx;
|
||||
match res {
|
||||
Ok(entries) => {
|
||||
let path = if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].remote_path.clone()
|
||||
} else {
|
||||
"/".into()
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
if path != "/" {
|
||||
let parent = remote_pane::remote_parent(&path);
|
||||
list.push(FileEntry {
|
||||
name: "..".into(),
|
||||
path: parent,
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
});
|
||||
}
|
||||
list.extend(entries);
|
||||
if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].remote_entries = list;
|
||||
self.state.tabs[tab_idx].loading = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].loading = false;
|
||||
}
|
||||
self.state.status_message = format!("Remote list error: {}", e);
|
||||
}
|
||||
}
|
||||
done.push(i);
|
||||
}
|
||||
}
|
||||
for i in done.into_iter().rev() {
|
||||
self.state.pending_remote_list.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
//! Основная рабочая область: тулбар, статус-бар, очередь, сайдбар и док-вкладки.
|
||||
use egui::{CentralPanel, SidePanel, TopBottomPanel};
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::dock::{AppTabViewer, PaneTab};
|
||||
use crate::ui::panels::{queue, sidebar, status_bar, toolbar};
|
||||
use crate::ui::theme::*;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_main_ui(&mut self, ctx: &egui::Context, fade: f32) {
|
||||
// Панели используют show_animated: тумблеры View → Toolbar/Sidebar/...
|
||||
// плавно въезжают/выезжают вместо мгновенного скачка раскладки.
|
||||
// ── Toolbar ──────────────────────────────────────────────────────────
|
||||
TopBottomPanel::top("toolbar")
|
||||
.frame(egui::Frame::none().fill(BG_TOOLBAR))
|
||||
.show_animated(ctx, self.state.show_toolbar, |ui| {
|
||||
toolbar::render(ui, &mut self.state, &self.queue);
|
||||
});
|
||||
|
||||
// ── Status bar ───────────────────────────────────────────────────────
|
||||
TopBottomPanel::bottom("status_bar")
|
||||
.frame(egui::Frame::none().fill(BG_BASE))
|
||||
.exact_height(STATUS_H)
|
||||
.show_animated(ctx, self.state.show_status_bar, |ui| {
|
||||
status_bar::render(ui, &self.state);
|
||||
});
|
||||
|
||||
// ── Queue panel ──────────────────────────────────────────────────────
|
||||
if self.state.show_queue_panel {
|
||||
let tasks = self.state.queue_tasks.clone();
|
||||
// Плавное раскрытие/сворачивание очереди по клику на заголовок.
|
||||
let expanded = self.state.show_queue && !tasks.is_empty();
|
||||
let t = ctx.animate_bool_with_time(egui::Id::new("queue_expanded"), expanded, 0.18);
|
||||
let q_h = egui::lerp(QUEUE_COLLAPSED_H..=QUEUE_EXPANDED_H, t);
|
||||
TopBottomPanel::bottom("queue_panel")
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(BG_QUEUE)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER)),
|
||||
)
|
||||
.resizable(false)
|
||||
.exact_height(q_h)
|
||||
.show(ctx, |ui| {
|
||||
queue::render(ui, &mut self.state, &tasks);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────
|
||||
SidePanel::left("sidebar")
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER)),
|
||||
)
|
||||
.resizable(true)
|
||||
.default_width(SIDEBAR_W)
|
||||
.width_range(120.0..=260.0)
|
||||
.show_animated(ctx, self.state.show_sidebar, |ui| {
|
||||
sidebar::render(ui, &mut self.state, &self.db);
|
||||
});
|
||||
|
||||
// ── Main content: модульная рабочая область (Local + подключения) ────
|
||||
// Каждое подключение — своя вкладка, которую можно перетаскивать,
|
||||
// разбивать на сплиты или закрывать независимо от остальных.
|
||||
CentralPanel::default()
|
||||
.frame(egui::Frame::none().fill(BG_CONTENT))
|
||||
.show(ctx, |ui| {
|
||||
ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade));
|
||||
let mut closed_remote: Option<String> = None;
|
||||
let mut viewer = AppTabViewer {
|
||||
state: &mut self.state,
|
||||
queue: &self.queue,
|
||||
registry: &self.registry,
|
||||
rt_handle: self.rt.handle(),
|
||||
db: &self.db,
|
||||
closed_remote: &mut closed_remote,
|
||||
};
|
||||
let style = egui_dock::Style::from_egui(ui.style());
|
||||
egui_dock::DockArea::new(&mut self.dock_state)
|
||||
.style(style)
|
||||
.show_add_buttons(false)
|
||||
.show_close_buttons(true)
|
||||
.show_inside(ui, &mut viewer);
|
||||
|
||||
if let Some((_rect, tab)) = self.dock_state.find_active_focused() {
|
||||
match tab {
|
||||
PaneTab::Local => self.state.active_pane = crate::ui::state::Pane::Local,
|
||||
PaneTab::Remote(id) => {
|
||||
if let Some(idx) = self.state.tabs.iter().position(|t| &t.id == id) {
|
||||
self.state.active_tab = idx;
|
||||
self.state.active_pane = crate::ui::state::Pane::Remote;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = closed_remote {
|
||||
self.registry.remove(&id);
|
||||
self.state.tabs.retain(|t| t.id != id);
|
||||
if self.state.active_tab >= self.state.tabs.len() {
|
||||
self.state.active_tab = self.state.tabs.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
//! Settings → About: версия, путь к БД, лицензия.
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use super::{FileManagerApp, section_title, value_row};
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_about(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.about.title"));
|
||||
|
||||
value_row(ui, t("settings.about.version"), env!("CARGO_PKG_VERSION"));
|
||||
value_row(ui, t("settings.about.license"), env!("CARGO_PKG_LICENSE"));
|
||||
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("loflum")
|
||||
.join("loflum.db");
|
||||
value_row(ui, t("settings.about.database"), &db_path.to_string_lossy());
|
||||
|
||||
ui.add_space(10.0);
|
||||
let reveal = egui::Button::new(
|
||||
RichText::new(t("settings.about.reveal_in_finder"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(11.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, reveal).clicked()
|
||||
&& let Some(dir) = db_path.parent()
|
||||
{
|
||||
let _ = crate::fs::local::open(&dir.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
//! Settings → Sites & Bookmarks / History / Security — всё, что относится к
|
||||
//! сохранённым подключениям, в одном файле (соответствует группе "CONNECTIONS"
|
||||
//! в левом меню).
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use super::{FileManagerApp, empty_state, hint, list_row_with_action, section_title};
|
||||
use crate::i18n::{t, tf};
|
||||
use crate::ui::icons::Icon;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_connections(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.connections.sites"));
|
||||
if self.sites.is_empty() {
|
||||
empty_state(ui, t("settings.connections.no_sites"));
|
||||
} else {
|
||||
let mut remove_idx: Option<usize> = None;
|
||||
for (i, site) in self.sites.iter().enumerate() {
|
||||
let sublabel = format!("{}@{}:{}", site.username, site.host, site.port);
|
||||
if list_row_with_action(
|
||||
ui,
|
||||
&site.name,
|
||||
&sublabel,
|
||||
Icon::Trash,
|
||||
t("settings.connections.delete_site_hover"),
|
||||
) {
|
||||
remove_idx = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = remove_idx {
|
||||
let site = self.sites.remove(i);
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let _ = crate::storage::db::delete_site(&conn, &site.id);
|
||||
}
|
||||
let _ = crate::storage::keychain::delete_password(&site.id);
|
||||
self.state.status_message =
|
||||
tf("settings.connections.removed_site", &[("{name}", &site.name)]);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(20.0);
|
||||
section_title(ui, t("settings.connections.bookmarks_title"));
|
||||
if self.state.bookmarks.is_empty() {
|
||||
empty_state(ui, t("settings.connections.no_bookmarks"));
|
||||
} else {
|
||||
let mut remove_id: Option<i64> = None;
|
||||
for bm in &self.state.bookmarks {
|
||||
if list_row_with_action(
|
||||
ui,
|
||||
&bm.name,
|
||||
&bm.path,
|
||||
Icon::Trash,
|
||||
t("panels.sidebar.remove_bookmark"),
|
||||
) {
|
||||
remove_id = Some(bm.id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = remove_id {
|
||||
self.state.bookmarks.retain(|b| b.id != id);
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let _ = crate::storage::db::remove_bookmark(&conn, id);
|
||||
}
|
||||
self.state.status_message = t("settings.connections.bookmark_removed").into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_history_section(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.history.title"));
|
||||
|
||||
if self.state.history.is_empty() {
|
||||
empty_state(ui, t("settings.history.empty"));
|
||||
} else {
|
||||
for entry in &self.state.history {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("{}@{}:{}", entry.user, entry.host, entry.port))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5));
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
if self.state.history_clear_confirm {
|
||||
hint(ui, t("settings.history.confirm_clear"));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let confirm = egui::Button::new(
|
||||
RichText::new(t("settings.history.delete_all"))
|
||||
.color(ON_ACCENT)
|
||||
.size(12.0),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::danger(ui, confirm).clicked() {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let _ = crate::storage::db::clear_history(&conn);
|
||||
}
|
||||
self.state.history.clear();
|
||||
self.state.history_clear_confirm = false;
|
||||
self.state.status_message = t("settings.history.cleared").into();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let cancel = egui::Button::new(
|
||||
RichText::new(t("common.cancel")).color(TEXT_DIM).size(12.0),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, cancel).clicked() {
|
||||
self.state.history_clear_confirm = false;
|
||||
}
|
||||
});
|
||||
} else if !self.state.history.is_empty() {
|
||||
let clear = egui::Button::new(
|
||||
RichText::new(t("settings.history.clear_all"))
|
||||
.color(RED)
|
||||
.size(12.0),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, clear).clicked() {
|
||||
self.state.history_clear_confirm = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Не показываем, есть ли пароль у конкретной записи — это потребовало бы
|
||||
/// вызвать `keychain::get_password`, который может дёрнуть системный запрос
|
||||
/// доступа к keychain на каждую запись при каждом открытии Settings.
|
||||
pub(super) fn render_security(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.nav.security"));
|
||||
hint(ui, t("settings.security.hint"));
|
||||
ui.add_space(12.0);
|
||||
|
||||
if self.sites.is_empty() {
|
||||
empty_state(ui, t("settings.security.no_connections"));
|
||||
} else {
|
||||
let mut forget_id: Option<String> = None;
|
||||
for site in &self.sites {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&site.name).color(TEXT_PRIMARY).size(12.5));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let btn = egui::Button::image(crate::ui::icons::image(
|
||||
Icon::LockPassword,
|
||||
13.0,
|
||||
TEXT_DIM,
|
||||
))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(26.0, 26.0));
|
||||
if button::ghost(ui, btn)
|
||||
.on_hover_text(t("settings.security.forget_password_hover"))
|
||||
.clicked()
|
||||
{
|
||||
forget_id = Some(site.id.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
if let Some(id) = forget_id {
|
||||
let _ = crate::storage::keychain::delete_password(&id);
|
||||
self.state.status_message = t("settings.security.password_forgotten").into();
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
let forget_all = egui::Button::new(
|
||||
RichText::new(t("settings.security.forget_all"))
|
||||
.color(RED)
|
||||
.size(12.0),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, forget_all).clicked() {
|
||||
for site in &self.sites {
|
||||
let _ = crate::storage::keychain::delete_password(&site.id);
|
||||
}
|
||||
for entry in &self.state.history {
|
||||
let _ = crate::storage::keychain::delete_password(&entry.conn_id);
|
||||
}
|
||||
self.state.status_message = t("settings.security.all_forgotten").into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
//! Settings → General & Appearance: подтверждение удаления, стартовая папка,
|
||||
//! язык интерфейса, видимость панелей (в согласии с меню Window на macOS).
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use super::prefs::{KEY_CONFIRM_DELETE, KEY_DEFAULT_LOCAL_FOLDER, KEY_LANGUAGE};
|
||||
use super::{FileManagerApp, hint, section_title};
|
||||
use crate::i18n::{Lang, t};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_general(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.nav.general"));
|
||||
|
||||
let changed = ui
|
||||
.checkbox(
|
||||
&mut self.state.confirm_before_delete,
|
||||
RichText::new(t("settings.general.confirm_delete"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5),
|
||||
)
|
||||
.changed();
|
||||
hint(ui, t("settings.general.confirm_delete_hint"));
|
||||
if changed {
|
||||
let value = self.state.confirm_before_delete;
|
||||
self.save_pref_bool(KEY_CONFIRM_DELETE, value);
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new(t("settings.general.language"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
);
|
||||
hint(ui, t("settings.general.language_hint"));
|
||||
ui.add_space(6.0);
|
||||
if let Some(lang) = language_picker(ui) {
|
||||
self.set_language(ui, lang);
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new(t("settings.general.startup_folder"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
);
|
||||
hint(ui, t("settings.general.startup_folder_hint"));
|
||||
ui.add_space(6.0);
|
||||
|
||||
let current = if self.state.default_local_folder.is_empty() {
|
||||
t("settings.general.home_directory").to_string()
|
||||
} else {
|
||||
self.state.default_local_folder.clone()
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(¤t)
|
||||
.color(TEXT_DIM)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let use_current = egui::Button::new(
|
||||
RichText::new(t("settings.general.use_current_folder"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(11.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, use_current).clicked() {
|
||||
let path = self.state.local_path.clone();
|
||||
self.state.default_local_folder = path.clone();
|
||||
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, &path);
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
let reset = egui::Button::new(
|
||||
RichText::new(t("settings.general.reset_to_home"))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.5),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 28.0));
|
||||
if button::ghost(ui, reset).clicked() {
|
||||
self.state.default_local_folder.clear();
|
||||
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn render_appearance(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.nav.appearance"));
|
||||
hint(ui, t("settings.appearance.hint"));
|
||||
ui.add_space(10.0);
|
||||
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.toolbar"),
|
||||
&mut self.state.show_toolbar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.sidebar"),
|
||||
&mut self.state.show_sidebar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.status_bar"),
|
||||
&mut self.state.show_status_bar,
|
||||
);
|
||||
appearance_checkbox(
|
||||
ui,
|
||||
t("settings.appearance.transfer_queue"),
|
||||
&mut self.state.show_queue_panel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Сохраняет выбор языка и сразу переключает `t()`/шрифты — эффект виден
|
||||
/// в этом же кадре, без перезапуска (см. правило про мгновенный отклик).
|
||||
fn set_language(&self, ui: &Ui, lang: Lang) {
|
||||
crate::i18n::set_lang(lang);
|
||||
self.refresh_fonts(ui.ctx());
|
||||
self.save_pref_str(KEY_LANGUAGE, lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance_checkbox(ui: &mut Ui, label: &str, value: &mut bool) {
|
||||
ui.checkbox(value, RichText::new(label).color(TEXT_PRIMARY).size(12.5));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
/// Выпадающий список из всех 12 языков — названия на них самих (Español,
|
||||
/// 日本語...), чтобы найти свой язык, даже не понимая текущего. Возвращает
|
||||
/// выбранный язык, если пользователь только что кликнул по другому пункту.
|
||||
fn language_picker(ui: &mut Ui) -> Option<Lang> {
|
||||
let current = crate::i18n::current();
|
||||
let mut picked = None;
|
||||
egui::ComboBox::from_id_salt("language_picker")
|
||||
.width(200.0)
|
||||
.selected_text(
|
||||
RichText::new(current.native_name())
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.0),
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
for lang in Lang::ALL {
|
||||
let selected = lang == current;
|
||||
if ui.selectable_label(selected, lang.native_name()).clicked() && !selected {
|
||||
picked = Some(lang);
|
||||
}
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
//! Диалог Settings — popup с меню разделов слева и содержимым справа
|
||||
//! (General/Appearance/Connections/History/Security/Transfers/About).
|
||||
use egui::{Align2, Context, RichText, Ui};
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::state::SettingsSection;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::{button, overlay};
|
||||
|
||||
mod about;
|
||||
mod connections;
|
||||
mod general;
|
||||
mod nav;
|
||||
pub(super) mod prefs;
|
||||
mod transfers;
|
||||
|
||||
const NAV_HEIGHT: f32 = 420.0;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_settings_dialog(&mut self, ctx: &Context, t: f32) {
|
||||
if overlay::dim(ctx, "settings_overlay", t) {
|
||||
self.state.show_settings_dialog = false;
|
||||
}
|
||||
|
||||
egui::Window::new("settings_dialog")
|
||||
.collapsible(false)
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.movable(false)
|
||||
.anchor(Align2::CENTER_CENTER, overlay::slide_offset(t))
|
||||
.interactable(t >= 1.0)
|
||||
.frame(overlay::panel_frame())
|
||||
.show(ctx, |ui| {
|
||||
ui.set_opacity(overlay::ease_out(t));
|
||||
ui.set_width(600.0);
|
||||
header(ui, &mut self.state.show_settings_dialog);
|
||||
ui.add_space(12.0);
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, BORDER);
|
||||
ui.add_space(14.0);
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
// ScrollArea наследует layout родителя, а здесь он
|
||||
// горизонтальный — без ui.vertical() содержимое скроллов
|
||||
// растекается в одну строку и окно вылезает за экран.
|
||||
ui.vertical(|ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("settings_nav_scroll")
|
||||
.max_height(NAV_HEIGHT)
|
||||
.show(ui, |ui| {
|
||||
nav::render(ui, &mut self.state.settings_section);
|
||||
});
|
||||
});
|
||||
|
||||
let (_, vsep) = ui.allocate_space(egui::vec2(1.0, NAV_HEIGHT));
|
||||
ui.painter().rect_filled(vsep, 0.0, BORDER);
|
||||
ui.add_space(18.0);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
// Кросс-фейд содержимого при переключении раздела:
|
||||
// фиксируем момент смены и плавно поднимаем прозрачность.
|
||||
if self.state.settings_section != self.state.settings_prev_section {
|
||||
self.state.settings_prev_section = self.state.settings_section;
|
||||
self.state.settings_section_changed_at = ui.input(|i| i.time);
|
||||
}
|
||||
let fade = ((ui.input(|i| i.time) - self.state.settings_section_changed_at)
|
||||
/ 0.18)
|
||||
.clamp(0.0, 1.0) as f32;
|
||||
if fade < 1.0 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.multiply_opacity(overlay::ease_out(fade));
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("settings_content_scroll")
|
||||
.max_height(NAV_HEIGHT)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(370.0);
|
||||
match self.state.settings_section {
|
||||
SettingsSection::General => self.render_general(ui),
|
||||
SettingsSection::Appearance => self.render_appearance(ui),
|
||||
SettingsSection::Connections => self.render_connections(ui),
|
||||
SettingsSection::History => self.render_history_section(ui),
|
||||
SettingsSection::Security => self.render_security(ui),
|
||||
SettingsSection::Transfers => self.render_transfers(ui),
|
||||
SettingsSection::About => self.render_about(ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn header(ui: &mut Ui, show: &mut bool) {
|
||||
ui.horizontal(|ui| {
|
||||
crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(crate::i18n::t("settings.title"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(15.0)
|
||||
.strong(),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(24.0, 24.0));
|
||||
if button::ghost(ui, close).clicked() {
|
||||
*show = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Заголовок раздела ("General", "Transfers"...), одинаковый во всех секциях.
|
||||
pub(super) fn section_title(ui: &mut Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(TEXT_PRIMARY).size(15.0).strong());
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
|
||||
/// Подсказка серым текстом под заголовком/контролом.
|
||||
pub(super) fn hint(ui: &mut Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(TEXT_HINT).size(11.0));
|
||||
}
|
||||
|
||||
/// Строка "метка — значение" только для чтения (версия, путь к БД и т.п.).
|
||||
/// Длинные значения (пути) усекаются с «…», полный текст — по наведению.
|
||||
pub(super) fn value_row(ui: &mut Ui, label: &str, value: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(11.5));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let text = RichText::new(value).color(TEXT_HINT).monospace().size(10.5);
|
||||
let resp = ui.add(egui::Label::new(text).truncate());
|
||||
if resp.hovered() && value.len() > 32 {
|
||||
resp.on_hover_text(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Пустое состояние списка ("No saved sites yet." и т.п.).
|
||||
pub(super) fn empty_state(ui: &mut Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(TEXT_HINT).size(11.5));
|
||||
}
|
||||
|
||||
/// Строка элемента списка с кнопкой-иконкой справа (удалить/забыть и т.п.).
|
||||
/// Возвращает `true`, если по кнопке кликнули.
|
||||
pub(super) fn list_row_with_action(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
sublabel: &str,
|
||||
action_icon: crate::ui::icons::Icon,
|
||||
action_hover: &str,
|
||||
) -> bool {
|
||||
let mut clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5));
|
||||
if !sublabel.is_empty() {
|
||||
ui.label(RichText::new(sublabel).color(TEXT_HINT).size(10.5));
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let btn = egui::Button::image(crate::ui::icons::image(action_icon, 13.0, TEXT_DIM))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(26.0, 26.0));
|
||||
if button::ghost(ui, btn).on_hover_text(action_hover).clicked() {
|
||||
clicked = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Сегментированный ряд из равных по ширине кнопок (используется в Transfers
|
||||
/// для выбора числа/интервала) — тот же паттерн, что и выбор протокола в
|
||||
/// диалоге New Connection.
|
||||
pub(super) fn segmented_row<T: Copy + PartialEq>(
|
||||
ui: &mut Ui,
|
||||
options: &[(&str, T)],
|
||||
current: T,
|
||||
) -> Option<T> {
|
||||
let mut picked = None;
|
||||
let gap = 4.0;
|
||||
let n = options.len().max(1) as f32;
|
||||
// На первом кадре свежего egui::Window ширина ещё не устоялась и может
|
||||
// быть меньше суммарных отступов — clamp защищает от negative desired_size.
|
||||
let seg_w = ((ui.available_width() - gap * (n - 1.0) - 6.0) / n).max(0.0);
|
||||
|
||||
egui::Frame::none()
|
||||
.fill(BG_CONTENT)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin::same(3.0))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
ui.horizontal(|ui| {
|
||||
for (label, value) in options {
|
||||
let active = *value == current;
|
||||
let tc = if active { ON_ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(*label).color(tc).size(11.5))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(seg_w, 26.0));
|
||||
let resp = if active {
|
||||
button::accent(ui, btn)
|
||||
} else {
|
||||
button::ghost(ui, btn)
|
||||
};
|
||||
if resp.clicked() {
|
||||
picked = Some(*value);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
//! Левое меню диалога Settings: сгруппированные пункты разделов.
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use crate::i18n::t;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::state::SettingsSection;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::row::clickable_row;
|
||||
|
||||
struct NavItem {
|
||||
section: SettingsSection,
|
||||
label_key: &'static str,
|
||||
icon: Icon,
|
||||
}
|
||||
|
||||
struct NavGroup {
|
||||
label_key: &'static str,
|
||||
items: &'static [NavItem],
|
||||
}
|
||||
|
||||
const GENERAL: &[NavItem] = &[
|
||||
NavItem {
|
||||
section: SettingsSection::General,
|
||||
label_key: "settings.nav.general",
|
||||
icon: Icon::Settings,
|
||||
},
|
||||
NavItem {
|
||||
section: SettingsSection::Appearance,
|
||||
label_key: "settings.nav.appearance",
|
||||
icon: Icon::Monitor,
|
||||
},
|
||||
];
|
||||
|
||||
const CONNECTIONS: &[NavItem] = &[
|
||||
NavItem {
|
||||
section: SettingsSection::Connections,
|
||||
label_key: "settings.nav.connections",
|
||||
icon: Icon::ServerSquare,
|
||||
},
|
||||
NavItem {
|
||||
section: SettingsSection::History,
|
||||
label_key: "settings.nav.history",
|
||||
icon: Icon::History,
|
||||
},
|
||||
NavItem {
|
||||
section: SettingsSection::Security,
|
||||
label_key: "settings.nav.security",
|
||||
icon: Icon::ShieldKeyhole,
|
||||
},
|
||||
];
|
||||
|
||||
const TRANSFERS: &[NavItem] = &[NavItem {
|
||||
section: SettingsSection::Transfers,
|
||||
label_key: "settings.nav.transfers",
|
||||
icon: Icon::Upload,
|
||||
}];
|
||||
|
||||
const ABOUT: &[NavItem] = &[NavItem {
|
||||
section: SettingsSection::About,
|
||||
label_key: "settings.nav.about",
|
||||
icon: Icon::Document,
|
||||
}];
|
||||
|
||||
const GROUPS: &[NavGroup] = &[
|
||||
NavGroup {
|
||||
label_key: "settings.nav.group_general",
|
||||
items: GENERAL,
|
||||
},
|
||||
NavGroup {
|
||||
label_key: "settings.nav.group_connections",
|
||||
items: CONNECTIONS,
|
||||
},
|
||||
NavGroup {
|
||||
label_key: "settings.nav.group_transfers",
|
||||
items: TRANSFERS,
|
||||
},
|
||||
NavGroup {
|
||||
label_key: "settings.nav.group_about",
|
||||
items: ABOUT,
|
||||
},
|
||||
];
|
||||
|
||||
pub(super) fn render(ui: &mut Ui, active: &mut SettingsSection) {
|
||||
ui.set_width(168.0);
|
||||
for group in GROUPS {
|
||||
ui.label(
|
||||
RichText::new(t(group.label_key))
|
||||
.color(TEXT_HINT)
|
||||
.size(10.0)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
for item in group.items {
|
||||
let is_active = *active == item.section;
|
||||
let icon_col = if is_active { ACCENT } else { TEXT_DIM };
|
||||
let resp = clickable_row(ui, is_active, 30.0, |ui| {
|
||||
icons::icon(ui, item.icon, 14.0, icon_col);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(t(item.label_key)).color(TEXT_PRIMARY).size(12.5));
|
||||
});
|
||||
if resp.clicked() {
|
||||
*active = item.section;
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
//! Ключи настроек в БД, значения по умолчанию и helpers сохранения —
|
||||
//! общие для всех разделов Settings.
|
||||
use super::FileManagerApp;
|
||||
|
||||
pub(super) const KEY_CONFIRM_DELETE: &str = "confirm_before_delete";
|
||||
pub(super) const KEY_DEFAULT_LOCAL_FOLDER: &str = "default_local_folder";
|
||||
pub(super) const KEY_AUTO_CLEAR_SECS: &str = "auto_clear_completed_secs";
|
||||
pub(super) const KEY_MAX_CONCURRENT: &str = "max_concurrent_transfers";
|
||||
pub(super) const KEY_LANGUAGE: &str = "language";
|
||||
pub(super) const DEFAULT_MAX_CONCURRENT: u32 = 2;
|
||||
|
||||
/// Настройки, загруженные из БД при старте (см. `FileManagerApp::new`).
|
||||
pub(in crate::ui::app) struct Prefs {
|
||||
pub confirm_before_delete: bool,
|
||||
pub default_local_folder: String,
|
||||
pub auto_clear_completed_secs: u32,
|
||||
pub max_concurrent: u32,
|
||||
/// `None` — язык ещё ни разу не выбирался явно, стартуем от локали ОС.
|
||||
pub language: Option<crate::i18n::Lang>,
|
||||
}
|
||||
|
||||
pub(in crate::ui::app) fn load(
|
||||
db: &std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
) -> Prefs {
|
||||
let Ok(conn) = db.lock() else {
|
||||
return Prefs {
|
||||
confirm_before_delete: true,
|
||||
default_local_folder: String::new(),
|
||||
auto_clear_completed_secs: 0,
|
||||
max_concurrent: DEFAULT_MAX_CONCURRENT,
|
||||
language: None,
|
||||
};
|
||||
};
|
||||
Prefs {
|
||||
confirm_before_delete: crate::storage::db::get_bool(&conn, KEY_CONFIRM_DELETE, true),
|
||||
default_local_folder: crate::storage::db::get_setting(&conn, KEY_DEFAULT_LOCAL_FOLDER)
|
||||
.unwrap_or_default(),
|
||||
auto_clear_completed_secs: crate::storage::db::get_u32(&conn, KEY_AUTO_CLEAR_SECS, 0),
|
||||
max_concurrent: crate::storage::db::get_u32(
|
||||
&conn,
|
||||
KEY_MAX_CONCURRENT,
|
||||
DEFAULT_MAX_CONCURRENT,
|
||||
),
|
||||
language: crate::storage::db::get_setting(&conn, KEY_LANGUAGE)
|
||||
.and_then(|c| crate::i18n::Lang::from_code(&c)),
|
||||
}
|
||||
}
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn save_pref_bool(&self, key: &str, value: bool) {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
crate::storage::db::set_bool(&conn, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_pref_u32(&self, key: &str, value: u32) {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
crate::storage::db::set_u32(&conn, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn save_pref_str(&self, key: &str, value: &str) {
|
||||
if let Ok(conn) = self.db.lock() {
|
||||
let _ = crate::storage::db::set_setting(&conn, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
//! Settings → Transfers: параллелизм передач и авто-очистка завершённых задач.
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
use super::prefs::{KEY_AUTO_CLEAR_SECS, KEY_MAX_CONCURRENT};
|
||||
use super::{FileManagerApp, hint, section_title, segmented_row};
|
||||
use crate::i18n::t;
|
||||
use crate::ui::theme::TEXT_PRIMARY;
|
||||
|
||||
const CONCURRENCY_OPTIONS: &[(&str, u32)] =
|
||||
&[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6)];
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_transfers(&mut self, ui: &mut Ui) {
|
||||
section_title(ui, t("settings.nav.transfers"));
|
||||
|
||||
ui.label(
|
||||
RichText::new(t("settings.transfers.concurrent_title"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
);
|
||||
hint(ui, t("settings.transfers.concurrent_hint"));
|
||||
ui.add_space(6.0);
|
||||
|
||||
let current = self
|
||||
.state
|
||||
.max_concurrent
|
||||
.load(Ordering::Relaxed)
|
||||
.clamp(1, 6);
|
||||
if let Some(picked) = segmented_row(ui, CONCURRENCY_OPTIONS, current) {
|
||||
self.state.max_concurrent.store(picked, Ordering::Relaxed);
|
||||
self.save_pref_u32(KEY_MAX_CONCURRENT, picked);
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new(t("settings.transfers.auto_remove_title"))
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
);
|
||||
hint(ui, t("settings.transfers.auto_remove_hint"));
|
||||
ui.add_space(6.0);
|
||||
|
||||
let auto_clear_options: &[(&str, u32)] = &[
|
||||
(t("settings.transfers.never"), 0),
|
||||
(t("settings.transfers.10s"), 10),
|
||||
(t("settings.transfers.30s"), 30),
|
||||
(t("settings.transfers.1min"), 60),
|
||||
];
|
||||
let current = self.state.auto_clear_completed_secs;
|
||||
if let Some(picked) = segmented_row(ui, auto_clear_options, current) {
|
||||
self.state.auto_clear_completed_secs = picked;
|
||||
self.save_pref_u32(KEY_AUTO_CLEAR_SECS, picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
//! Тема egui (цвета/отступы) и системный шрифт.
|
||||
use egui::Visuals;
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::theme::*;
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn apply_visuals(&self, ctx: &egui::Context) {
|
||||
let mut vis = Visuals::dark();
|
||||
vis.window_fill = BG_PANEL;
|
||||
vis.panel_fill = BG_CONTENT;
|
||||
vis.extreme_bg_color = BG_BASE;
|
||||
vis.code_bg_color = BG_BASE;
|
||||
vis.override_text_color = Some(TEXT_PRIMARY);
|
||||
vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER);
|
||||
vis.widgets.inactive.bg_fill = BG_CONTENT;
|
||||
vis.widgets.hovered.bg_fill = BG_ROW_HOVER;
|
||||
vis.widgets.active.bg_fill = ACCENT_DIM;
|
||||
vis.selection.bg_fill = BG_ROW_SEL;
|
||||
vis.selection.stroke = egui::Stroke::new(1.0, ACCENT);
|
||||
vis.window_rounding = egui::Rounding::same(8.0);
|
||||
ctx.set_visuals(vis);
|
||||
|
||||
self.refresh_fonts(ctx);
|
||||
|
||||
let mut style = ctx.style().as_ref().clone();
|
||||
style.spacing.item_spacing = egui::vec2(4.0, 2.0);
|
||||
style.spacing.button_padding = egui::vec2(8.0, 4.0);
|
||||
ctx.set_style(style);
|
||||
}
|
||||
|
||||
/// Перезагружает шрифты под текущий язык (см. [`crate::i18n::current`]) —
|
||||
/// вызывается при старте и заново при смене языка в Settings, потому что
|
||||
/// SF Pro не содержит глифов CJK (китайский/японский/корейский).
|
||||
pub(super) fn refresh_fonts(&self, ctx: &egui::Context) {
|
||||
ctx.set_fonts(system_fonts(crate::i18n::current()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта,
|
||||
/// чтобы интерфейс выглядел нативно, плюс CJK-шрифты фолбэком (SF Pro не
|
||||
/// содержит китайских/японских/корейских глифов — без фолбэка был бы tofu).
|
||||
/// На других ОС файлы просто отсутствуют — тогда остаются встроенные шрифты egui.
|
||||
fn system_fonts(lang: crate::i18n::Lang) -> egui::FontDefinitions {
|
||||
use crate::i18n::Lang;
|
||||
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
|
||||
// Основной шрифт всегда первый в списке: даёт консистентную латиницу и
|
||||
// кириллицу во всём интерфейсе. CJK-фонты идут строго ПОСЛЕ — иначе их
|
||||
// собственное начертание латинских букв/цифр перекрасило бы весь текст,
|
||||
// а не только иероглифы/кану/хангыль, которых нет в SF Pro.
|
||||
let mut candidates: Vec<(&str, &str, egui::FontFamily)> = vec![
|
||||
(
|
||||
"system-sans",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
egui::FontFamily::Proportional,
|
||||
),
|
||||
(
|
||||
"system-mono",
|
||||
"/System/Library/Fonts/SFNSMono.ttf",
|
||||
egui::FontFamily::Monospace,
|
||||
),
|
||||
];
|
||||
|
||||
// CJK Unified Ideographs пересекаются между китайским/японским/корейским
|
||||
// (ханьцзы/кандзи/ханча — одни кодпоинты), поэтому шрифт активного языка
|
||||
// ставим первым среди CJK-фолбэков: он и определит начертание общих иероглифов.
|
||||
let mut cjk: Vec<(&str, &str)> = vec![
|
||||
("cjk-ja", "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc"),
|
||||
("cjk-zh", "/System/Library/Fonts/Hiragino Sans GB.ttc"),
|
||||
("cjk-ko", "/System/Library/Fonts/AppleSDGothicNeo.ttc"),
|
||||
];
|
||||
let active = match lang {
|
||||
Lang::Ja => Some("cjk-ja"),
|
||||
Lang::Zh => Some("cjk-zh"),
|
||||
Lang::Ko => Some("cjk-ko"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(active) = active
|
||||
&& let Some(pos) = cjk.iter().position(|(name, _)| *name == active)
|
||||
{
|
||||
cjk.swap(0, pos);
|
||||
}
|
||||
candidates.extend(cjk.into_iter().map(|(n, p)| (n, p, egui::FontFamily::Proportional)));
|
||||
|
||||
for (name, path, family) in candidates {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
fonts
|
||||
.font_data
|
||||
.entry(name.to_owned())
|
||||
.or_insert_with(|| egui::FontData::from_owned(bytes));
|
||||
let list = fonts.families.entry(family).or_default();
|
||||
if !list.iter().any(|n| n == name) {
|
||||
list.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fonts
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
//! Стартовый экран — пока нет ни одного открытого соединения: логотип,
|
||||
//! кнопка нового подключения, индикатор подключения и список недавних серверов.
|
||||
use egui::{CentralPanel, RichText};
|
||||
|
||||
use super::FileManagerApp;
|
||||
use crate::ui::dialogs::connection;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::{button, row::clickable_row};
|
||||
|
||||
impl FileManagerApp {
|
||||
pub(super) fn render_welcome_screen(&mut self, ctx: &egui::Context, fade: f32) {
|
||||
CentralPanel::default()
|
||||
.frame(egui::Frame::none().fill(BG_BASE))
|
||||
.show(ctx, |ui| {
|
||||
ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade));
|
||||
ui.vertical_centered(|ui| {
|
||||
let avail_h = ui.available_height();
|
||||
ui.add_space((avail_h * 0.16).max(24.0));
|
||||
|
||||
let icon_bytes: &[u8] = include_bytes!("../icons/app_icon.png");
|
||||
let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes)
|
||||
.rounding(RADIUS_LG)
|
||||
.fit_to_exact_size(egui::vec2(72.0, 72.0));
|
||||
ui.add(icon_img);
|
||||
|
||||
ui.add_space(14.0);
|
||||
ui.label(
|
||||
RichText::new("LoFlum")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(20.0)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5));
|
||||
|
||||
ui.add_space(20.0);
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(Icon::AddCircleBold, 15.0, ON_ACCENT),
|
||||
RichText::new("New Connection")
|
||||
.color(ON_ACCENT)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(170.0, 36.0));
|
||||
if button::accent(ui, btn).clicked() {
|
||||
self.state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
self.connect_status(ui);
|
||||
|
||||
ui.add_space(28.0);
|
||||
self.recent_connections(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Мгновенная обратная связь при подключении из истории/приветствия: спиннер,
|
||||
/// а при неудаче — текст ошибки (иначе провал был бы беззвучным, т.к. на
|
||||
/// welcome-экране нет статус-бара).
|
||||
///
|
||||
/// Спиннер — единственный виджет, добавленный напрямую (без `ui.horizontal`):
|
||||
/// `ui.horizontal` по умолчанию растягивает свой прямоугольник на всю
|
||||
/// доступную ширину панели, и `vertical_centered` центрирует уже растянутый
|
||||
/// на всю ширину блок — то есть фактически не центрирует ничего, и
|
||||
/// содержимое просто прилипает к левому краю. Одиночный виджет всегда
|
||||
/// имеет свой собственный размер, поэтому центрируется корректно.
|
||||
fn connect_status(&self, ui: &mut egui::Ui) {
|
||||
if self.state.connect_loading {
|
||||
ui.add_space(14.0);
|
||||
ui.spinner();
|
||||
} else if !self.state.connect_error.is_empty() {
|
||||
ui.add_space(14.0);
|
||||
ui.label(
|
||||
RichText::new(&self.state.connect_error)
|
||||
.color(RED)
|
||||
.size(11.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn recent_connections(&mut self, ui: &mut egui::Ui) {
|
||||
if self.state.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let list_w = 320.0_f32.min(ui.available_width() - 40.0);
|
||||
|
||||
ui.label(
|
||||
RichText::new("RECENT CONNECTIONS")
|
||||
.color(TEXT_HINT)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
egui::Frame::none().show(ui, |ui| {
|
||||
ui.set_width(list_w);
|
||||
let history: Vec<_> = self.state.history.iter().take(8).cloned().collect();
|
||||
for entry in &history {
|
||||
let label = format!("{}@{}:{}", entry.user, entry.host, entry.port);
|
||||
let resp = clickable_row(ui, false, 34.0, |ui| {
|
||||
icons::icon(ui, Icon::ServerSquare, 13.0, TEXT_DIM);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(&label).color(TEXT_PRIMARY).size(12.5));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5));
|
||||
});
|
||||
});
|
||||
if resp.clicked() {
|
||||
self.state.pending_history_reconnect = Some(entry.clone());
|
||||
}
|
||||
crate::ui::widgets::context_menu::context_menu(&resp, |ui| {
|
||||
if ui.button("Edit").clicked() {
|
||||
connection::edit_history_entry(&mut self.state, entry);
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
self.state.pending_history_save = Some(entry.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
//! Логика подключения: сборка параметров и запуск async-коннекта.
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
|
||||
use crate::storage::keychain;
|
||||
use crate::ui::state::{AppState, PendingConnect};
|
||||
|
||||
pub(super) fn do_connect(
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
state.connect_error.clear();
|
||||
|
||||
if state.connect_host.trim().is_empty() {
|
||||
state.connect_error = "Host is required.".into();
|
||||
return;
|
||||
}
|
||||
if state.connect_user.trim().is_empty() {
|
||||
state.connect_error = "Username is required.".into();
|
||||
return;
|
||||
}
|
||||
if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::<u16>().is_err() {
|
||||
state.connect_error = "Port must be a number between 1 and 65535.".into();
|
||||
return;
|
||||
}
|
||||
|
||||
let protocol = match state.connect_protocol {
|
||||
0 => Protocol::Sftp,
|
||||
1 => Protocol::Ftp,
|
||||
_ => Protocol::Ftps,
|
||||
};
|
||||
|
||||
let port: u16 = state.connect_port.parse().unwrap_or(match protocol {
|
||||
Protocol::Sftp => 22,
|
||||
Protocol::Ftp => 21,
|
||||
Protocol::Ftps => 990,
|
||||
});
|
||||
|
||||
let params = ConnectionParams {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
label: if state.connect_label.is_empty() {
|
||||
format!(
|
||||
"{} ({})",
|
||||
state.connect_host,
|
||||
match protocol {
|
||||
Protocol::Sftp => "SFTP",
|
||||
Protocol::Ftp => "FTP",
|
||||
Protocol::Ftps => "FTPS",
|
||||
}
|
||||
)
|
||||
} else {
|
||||
state.connect_label.clone()
|
||||
},
|
||||
protocol,
|
||||
host: state.connect_host.clone(),
|
||||
port,
|
||||
username: state.connect_user.clone(),
|
||||
password: if state.connect_pass.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.connect_pass.clone())
|
||||
},
|
||||
key_path: if state.connect_key_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.connect_key_path.clone())
|
||||
},
|
||||
};
|
||||
|
||||
spawn_connect(state, registry, rt_handle, params);
|
||||
}
|
||||
|
||||
/// Общий код запуска подключения — используется и диалогом New Connection,
|
||||
/// и переподключением из истории (см. `reconnect_from_history`).
|
||||
pub fn spawn_connect(
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
params: ConnectionParams,
|
||||
) {
|
||||
let registry = registry.clone();
|
||||
let params_clone = params.clone();
|
||||
let result = Arc::new(std::sync::Mutex::new(None));
|
||||
let result_clone = result.clone();
|
||||
|
||||
state.connect_loading = true;
|
||||
rt_handle.spawn(async move {
|
||||
let r = connect_async(®istry, ¶ms_clone).await;
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state.pending_connect = Some(PendingConnect { result });
|
||||
}
|
||||
|
||||
async fn connect_async(
|
||||
registry: &RemoteRegistry,
|
||||
params: &ConnectionParams,
|
||||
) -> Result<(ConnectionParams, Vec<FileEntry>), String> {
|
||||
// Пароль резолвим лениво по месту: для ключевой/автоматической SSH-аутен-
|
||||
// тификации он не нужен, и обязательный поход в keychain раньше валил
|
||||
// подключение по ключу с пустым паролем ("нет пароля в keychain").
|
||||
let stored_password = || -> Option<String> {
|
||||
params
|
||||
.password
|
||||
.clone()
|
||||
.or_else(|| keychain::get_password(¶ms.id).ok())
|
||||
};
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
// Явно выбранный ключ; пароль из формы (если есть) — passphrase.
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else if let Some(password) = stored_password() {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
// Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh).
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
let password = stored_password().ok_or("Password is required for FTP connections.")?;
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs.clone());
|
||||
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
|
||||
Ok((params.clone(), entries))
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
//! Поля диалога подключения: сегментированный выбор протокола, текстовые
|
||||
//! поля с рамкой и подсветкой фокуса, блок ошибки.
|
||||
use egui::{Color32, RichText, Stroke, TextEdit, Ui};
|
||||
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::button;
|
||||
|
||||
const FIELD_HEIGHT: f32 = 32.0;
|
||||
|
||||
/// Сегментированный переключатель протокола — три равные по ширине секции
|
||||
/// в одной рамке, а не отдельные кнопки вразнобой (было похоже на кривой
|
||||
/// список из-за неравных промежутков и незаполненного остатка ширины).
|
||||
pub(super) fn protocol_row(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
protocols: &[&str; 3],
|
||||
default_ports: &[u16; 3],
|
||||
) {
|
||||
let gap = 4.0;
|
||||
let total_w = ui.available_width();
|
||||
let seg_w = ((total_w - gap * 2.0 - 6.0) / 3.0).max(0.0);
|
||||
|
||||
egui::Frame::none()
|
||||
.fill(BG_CONTENT)
|
||||
.stroke(Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin::same(3.0))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
ui.horizontal(|ui| {
|
||||
for (i, p) in protocols.iter().enumerate() {
|
||||
let active = state.connect_protocol == i;
|
||||
let tc = if active { ON_ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5).strong())
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(seg_w, 26.0));
|
||||
let resp = if active {
|
||||
button::accent(ui, btn)
|
||||
} else {
|
||||
button::ghost(ui, btn)
|
||||
};
|
||||
if resp.clicked() {
|
||||
state.connect_protocol = i;
|
||||
state.connect_port = default_ports[i].to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fields(ui: &mut Ui, state: &mut AppState) {
|
||||
let field_w = ui.available_width();
|
||||
|
||||
field(ui, "Host", &mut state.connect_host, field_w, false);
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let user_w = (field_w - 80.0 - 8.0).max(0.0);
|
||||
field(ui, "Username", &mut state.connect_user, user_w, false);
|
||||
ui.add_space(8.0);
|
||||
field(ui, "Port", &mut state.connect_port, 80.0, false);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
|
||||
field(ui, "Password", &mut state.connect_pass, field_w, true);
|
||||
|
||||
if state.connect_protocol == 0 {
|
||||
ui.add_space(3.0);
|
||||
ui.label(
|
||||
RichText::new("Leave empty to sign in with an SSH key (agent or ~/.ssh).")
|
||||
.color(TEXT_HINT)
|
||||
.size(10.0),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
key_field(ui, state, field_w);
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
field(
|
||||
ui,
|
||||
"Label (optional)",
|
||||
&mut state.connect_label,
|
||||
field_w,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn error_box(ui: &mut Ui, state: &AppState) {
|
||||
if state.connect_error.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bg = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 26);
|
||||
let border = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 100);
|
||||
ui.add_space(12.0);
|
||||
egui::Frame::none()
|
||||
.fill(bg)
|
||||
.stroke(Stroke::new(1.0, border))
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
crate::ui::icons::icon(ui, crate::ui::icons::Icon::DangerTriangle, 14.0, RED);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(&state.connect_error).color(RED).size(11.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Поле пути к SSH-ключу с кнопкой Browse… (нативный диалог выбора файла).
|
||||
fn key_field(ui: &mut Ui, state: &mut AppState, field_w: f32) {
|
||||
const BROWSE_W: f32 = 72.0;
|
||||
ui.label(
|
||||
RichText::new("Key file (optional)")
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
field_input(
|
||||
ui,
|
||||
&mut state.connect_key_path,
|
||||
(field_w - BROWSE_W - 8.0).max(0.0),
|
||||
false,
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let browse = egui::Button::new(RichText::new("Browse…").color(TEXT_DIM).size(11.5))
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(BROWSE_W, FIELD_HEIGHT));
|
||||
if button::ghost(ui, browse).clicked() {
|
||||
let ssh_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".ssh"))
|
||||
.filter(|d| d.is_dir())
|
||||
.or_else(dirs::home_dir);
|
||||
let mut dlg = rfd::FileDialog::new().set_title("Choose SSH key");
|
||||
if let Some(dir) = ssh_dir {
|
||||
dlg = dlg.set_directory(dir);
|
||||
}
|
||||
if let Some(path) = dlg.pick_file() {
|
||||
state.connect_key_path = path.display().to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Текстовое поле с меткой, рамкой и подсветкой фокуса/hover в акцентном цвете —
|
||||
/// раньше поля были обычными TextEdit без рамки, из-за чего вся форма выглядела
|
||||
/// незакреплённой/неровной на фоне остального интерфейса с рамками у карточек.
|
||||
fn field(ui: &mut Ui, label: &str, value: &mut String, width: f32, password: bool) {
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(11.0));
|
||||
ui.add_space(4.0);
|
||||
field_input(ui, value, width, password);
|
||||
}
|
||||
|
||||
/// Само поле ввода без метки (для составных рядов вроде «путь + Browse»).
|
||||
fn field_input(ui: &mut Ui, value: &mut String, width: f32, password: bool) {
|
||||
ui.scope(|ui| {
|
||||
let w = &mut ui.visuals_mut().widgets;
|
||||
w.inactive.rounding = RADIUS_MD.into();
|
||||
w.inactive.bg_stroke = Stroke::new(1.0, BORDER);
|
||||
w.hovered.rounding = RADIUS_MD.into();
|
||||
w.hovered.bg_stroke = Stroke::new(1.0, ACCENT_DIM);
|
||||
ui.visuals_mut().selection.stroke = Stroke::new(1.0, ACCENT);
|
||||
|
||||
let te = TextEdit::singleline(value)
|
||||
.password(password)
|
||||
.desired_width(width)
|
||||
.min_size(egui::vec2(width, FIELD_HEIGHT))
|
||||
.margin(egui::Margin::symmetric(10.0, 8.0))
|
||||
.font(egui::TextStyle::Body);
|
||||
ui.add(te);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
//! Действия над записями истории: переподключение, «Изменить», «Сохранить» как Site.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::actions::spawn_connect;
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::storage::keychain;
|
||||
use crate::ui::state::AppState;
|
||||
|
||||
fn protocol_index(protocol: &Protocol) -> usize {
|
||||
match protocol {
|
||||
Protocol::Sftp => 0,
|
||||
Protocol::Ftp => 1,
|
||||
Protocol::Ftps => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Переподключение по клику на запись истории — пароль (если есть) уже лежит
|
||||
/// в keychain под `entry.conn_id`, поэтому диалог не нужен.
|
||||
pub fn reconnect_from_history(
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
entry: &crate::ui::state::HistoryEntry,
|
||||
) {
|
||||
state.connect_error.clear();
|
||||
let params = ConnectionParams {
|
||||
id: entry.conn_id.clone(),
|
||||
label: format!("{}@{}", entry.user, entry.host),
|
||||
protocol: entry.protocol.clone(),
|
||||
host: entry.host.clone(),
|
||||
port: entry.port,
|
||||
username: entry.user.clone(),
|
||||
password: None,
|
||||
key_path: entry.key_path.clone(),
|
||||
};
|
||||
spawn_connect(state, registry, rt_handle, params);
|
||||
}
|
||||
|
||||
/// «Изменить» в меню истории — открывает диалог New Connection, предзаполненный
|
||||
/// этой записью (включая пароль из keychain, если он там есть).
|
||||
pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) {
|
||||
state.connect_error.clear();
|
||||
state.connect_host = entry.host.clone();
|
||||
state.connect_user = entry.user.clone();
|
||||
state.connect_port = entry.port.to_string();
|
||||
state.connect_protocol = protocol_index(&entry.protocol);
|
||||
state.connect_key_path = entry.key_path.clone().unwrap_or_default();
|
||||
state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default();
|
||||
state.connect_label.clear();
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
/// «Сохранить» в меню истории — превращает разовое подключение в постоянный Site.
|
||||
pub fn save_history_as_site(
|
||||
db: &Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
sites: &mut Vec<crate::domain::site::Site>,
|
||||
entry: &crate::ui::state::HistoryEntry,
|
||||
) -> Result<(), String> {
|
||||
let site = crate::domain::site::Site {
|
||||
id: entry.conn_id.clone(),
|
||||
name: format!("{}@{}", entry.user, entry.host),
|
||||
protocol: entry.protocol.clone(),
|
||||
host: entry.host.clone(),
|
||||
port: entry.port,
|
||||
username: entry.user.clone(),
|
||||
key_path: entry.key_path.clone(),
|
||||
folder: None,
|
||||
note: None,
|
||||
};
|
||||
let conn = db
|
||||
.lock()
|
||||
.map_err(|_| "database lock poisoned".to_string())?;
|
||||
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) {
|
||||
*existing = site;
|
||||
} else {
|
||||
sites.push(site);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
//! Диалог «New Connection» и связанные действия (подключение, история → Site).
|
||||
mod actions;
|
||||
mod form;
|
||||
mod history;
|
||||
mod view;
|
||||
|
||||
pub use actions::spawn_connect;
|
||||
pub use history::{edit_history_entry, reconnect_from_history, save_history_as_site};
|
||||
pub use view::render;
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
//! Отрисовка диалога «New Connection» (оболочка окна; поля — в [`super::form`]).
|
||||
use egui::{Align, Color32, Layout, RichText, Ui};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::actions::do_connect;
|
||||
use super::form;
|
||||
// Алиас `tr`: параметр `t` в этой функции — анимационная «открытость» (f32).
|
||||
use crate::i18n::t as tr;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use crate::ui::widgets::{button, overlay};
|
||||
|
||||
const DIALOG_WIDTH: f32 = 332.0;
|
||||
|
||||
pub fn render(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
t: f32,
|
||||
) {
|
||||
let protocols = ["SFTP", "FTP", "FTPS"];
|
||||
let default_ports: [u16; 3] = [22, 21, 990];
|
||||
|
||||
// Клик по затемнению закрывает диалог (кроме момента подключения).
|
||||
if overlay::dim(ctx, "connect_overlay", t) && !state.connect_loading {
|
||||
state.show_connect_dialog = false;
|
||||
}
|
||||
|
||||
// `egui::Window` с anchor(CENTER_CENTER) авто-подгоняет размер под контент
|
||||
// и всегда центрирует его заново — в отличие от Area с заранее посчитанным
|
||||
// размером окна, где реальная высота полей могла разойтись с предположенной
|
||||
// и диалог "уезжал" от центра.
|
||||
egui::Window::new("connect_dialog")
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.movable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, overlay::slide_offset(t))
|
||||
.interactable(t >= 1.0)
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_LG)
|
||||
.shadow(egui::Shadow {
|
||||
offset: egui::vec2(0.0, 10.0),
|
||||
blur: 28.0,
|
||||
spread: 0.0,
|
||||
color: Color32::from_black_alpha(130),
|
||||
})
|
||||
.inner_margin(egui::Margin::same(22.0)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_opacity(overlay::ease_out(t));
|
||||
ui.set_width(DIALOG_WIDTH);
|
||||
header(ui, state);
|
||||
ui.add_space(18.0);
|
||||
form::protocol_row(ui, state, &protocols, &default_ports);
|
||||
ui.add_space(16.0);
|
||||
form::fields(ui, state);
|
||||
form::error_box(ui, state);
|
||||
ui.add_space(18.0);
|
||||
buttons(ui, state, registry, rt_handle);
|
||||
|
||||
// Enter из любого поля запускает подключение (submit формы).
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if enter && t >= 1.0 && state.show_connect_dialog && !state.connect_loading {
|
||||
do_connect(state, registry, rt_handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn header(ui: &mut Ui, state: &mut AppState) {
|
||||
ui.horizontal(|ui| {
|
||||
crate::ui::icons::icon(ui, crate::ui::icons::Icon::ServerSquare, 15.0, ACCENT);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("New Connection")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(15.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0))
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(24.0, 24.0));
|
||||
if button::ghost(ui, close).clicked() {
|
||||
state.show_connect_dialog = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn buttons(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
let cancel = egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5))
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(90.0, 32.0));
|
||||
if button::ghost(ui, cancel).clicked() && !state.connect_loading {
|
||||
state.show_connect_dialog = false;
|
||||
}
|
||||
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.add_enabled_ui(!state.connect_loading, |ui| {
|
||||
let connect_label = if state.connect_loading {
|
||||
"Connecting…"
|
||||
} else {
|
||||
"Connect"
|
||||
};
|
||||
let connect_btn = egui::Button::new(
|
||||
RichText::new(connect_label)
|
||||
.color(ON_ACCENT)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(114.0, 32.0));
|
||||
|
||||
if button::accent(ui, connect_btn).clicked() {
|
||||
do_connect(state, registry, rt_handle);
|
||||
}
|
||||
});
|
||||
|
||||
if state.connect_loading {
|
||||
ui.add_space(8.0);
|
||||
ui.spinner();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub mod connection;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue