diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..39dc687 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +jobs = 6 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..64560bb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -10,13 +10,74 @@ env: CARGO_TERM_COLOR: always jobs: - build: - + lint: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libsoup-3.0-dev \ + libgtk-3-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libdbus-1-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --check + - name: Lint with clippy + run: cargo clippy -- -D warnings + + build: + runs-on: ubuntu-latest + needs: [lint] + steps: + - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libsoup-3.0-dev \ + libgtk-3-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libdbus-1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build (release) + run: cargo build --release + + test: + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libsoup-3.0-dev \ + libgtk-3-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libdbus-1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test diff --git a/README.md b/README.md index 5eab2fc..bf7eb70 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд — vanilla HTML/CSS/JS. +

+ Wherry main screen +
Welcome-экран с сохранёнными сайтами и историей подключений +

+ ## Стек - **Tauri 2** — нативная оболочка (Rust-бэкенд + WebView-фронтенд) @@ -23,7 +28,15 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд — |----------|--------------| | SFTP | реализован | | FTP | реализован | -| FTPS | заглушка | +| FTPS | реализован | + +## Скриншоты + +

+ Connect dialog + Settings screen +
Диалог подключения (слева) и окно настроек (справа) +

## Возможности @@ -34,8 +47,6 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд — - Welcome-экран: сохранённые сайты, недавние подключения, шорткаты - Settings: General / Appearance / Connections / History / Security / Transfers / About -В разработке: полноценная реализация FTPS, Site Manager UI (создание/редактирование сайтов). - ## Разработка ```bash diff --git a/docs/img/connect_screen.png b/docs/img/connect_screen.png new file mode 100644 index 0000000..e375e16 Binary files /dev/null and b/docs/img/connect_screen.png differ diff --git a/docs/img/settings_screen.png b/docs/img/settings_screen.png new file mode 100644 index 0000000..8d97c4a Binary files /dev/null and b/docs/img/settings_screen.png differ diff --git a/docs/img/start_screen.png b/docs/img/start_screen.png new file mode 100644 index 0000000..d4828fc Binary files /dev/null and b/docs/img/start_screen.png differ diff --git a/frontend/css/components.css b/frontend/css/components.css index 44cf84f..ea1086b 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -414,8 +414,8 @@ flex: 1; } .dialog-close { - width: 24px; - height: 24px; + width: 28px; + height: 28px; border-radius: var(--radius-sm); border: none; background: transparent; @@ -541,6 +541,10 @@ cursor: pointer; min-width: 180px; } +.settings-select option { + background: var(--bg-panel); + color: var(--text-primary); +} .settings-select:hover { border-color: var(--accent); } diff --git a/frontend/css/layout.css b/frontend/css/layout.css index d49b920..d2d47c2 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -305,8 +305,8 @@ color: var(--text-primary); } .tab-close { - width: 16px; - height: 16px; + width: 22px; + height: 22px; border-radius: 4px; border: none; background: transparent; @@ -314,13 +314,15 @@ display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 13px; line-height: 1; cursor: pointer; + opacity: 0.7; } .tab-close:hover { background: rgba(255, 255, 255, 0.12); color: var(--text-primary); + opacity: 1; } /* localPane.js/remotePane.js render straight into this element (there's no @@ -708,17 +710,17 @@ html[data-platform="linux"] ::-webkit-scrollbar { } .pane-split-btn, .pane-close-btn { - width: 18px; - height: 18px; + width: 24px; + height: 24px; border: none; - border-radius: 3px; + border-radius: 4px; background: transparent; color: var(--text-hint); display: flex; align-items: center; justify-content: center; cursor: pointer; - font-size: 10px; + font-size: 13px; padding: 0; } .pane-split-btn:hover, diff --git a/frontend/js/components/connectDialog.js b/frontend/js/components/connectDialog.js index a0a5fef..b578126 100644 --- a/frontend/js/components/connectDialog.js +++ b/frontend/js/components/connectDialog.js @@ -94,7 +94,7 @@ function build() {
${iconMarkup("serverSquare", 16)} ${t("connect.title")} - +
diff --git a/frontend/js/components/fileTable.js b/frontend/js/components/fileTable.js index d5c738c..3e89668 100644 --- a/frontend/js/components/fileTable.js +++ b/frontend/js/components/fileTable.js @@ -1,6 +1,3 @@ -// 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"; @@ -8,6 +5,11 @@ import { makeDraggable, makeDropTarget } from "../dnd.js"; import { showContextMenu } from "./contextMenu.js"; import { escapeHtml } from "../dom.js"; +const ROW_HEIGHT = 32; +const OVERSCAN = 10; + +let rafId = null; + function sortEntries(entries, sort) { const dotdotIdx = entries.findIndex((e) => e.name === ".."); const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null; @@ -57,29 +59,64 @@ function sortHeaderHtml(label, col, sort) { return ``; } -/** - * 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; +function computeVisibleRange(scrollTop, viewportHeight, totalItems) { + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const end = Math.min(totalItems, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN); + return { start, end }; +} + +function getSignature(sorted, sort) { + const len = sorted.length; + if (len === 0) return `0:${sort.col}:${sort.dir}`; + const mid = Math.min(len >> 1, len - 1); + return `${len}:${sort.col}:${sort.dir}:${sorted[0].name}:${sorted[mid].name}:${sorted[len - 1].name}`; +} + +function initStructure(container) { + let cache = container._fileTableCache; + if (cache) return cache; + container.innerHTML = ""; container.classList.add("file-table-wrap"); const header = document.createElement("div"); header.className = "file-table-header"; + container.appendChild(header); + + const body = document.createElement("div"); + body.className = "file-table-body"; + container.appendChild(body); + + const spacer = document.createElement("div"); + spacer.style.position = "relative"; + body.appendChild(spacer); + + cache = { + header, + body, + spacer, + lastSignature: null, + sorted: [], + selectableNames: [], + opts: null, + }; + container._fileTableCache = cache; + + body.addEventListener("scroll", () => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = null; + const c = container._fileTableCache; + if (c && c.sorted.length > 0) { + updateVisibleRows(c); + } + }); + }); + + return cache; +} + +function updateHeader(header, opts) { header.innerHTML = ` ${sortHeaderHtml(t("panels.colName"), "name", opts.sort)} ${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)} @@ -96,81 +133,141 @@ export function renderFileTable(container, opts) { opts.onSort(next); }); }); - container.appendChild(header); +} - const body = document.createElement("div"); - body.className = "file-table-body"; - container.appendChild(body); +function createRowElement(entry, index, cache) { + const row = document.createElement("div"); + row.className = "file-row"; + row.dataset.index = index; - const sorted = sortEntries(opts.entries, opts.sort); - const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name); + const current = cache.opts; + if (current.selected.includes(entry.name)) row.classList.add("selected"); - for (const entry of sorted) { - const row = document.createElement("div"); - row.className = "file-row"; - if (opts.selected.includes(entry.name)) row.classList.add("selected"); + row.style.position = "absolute"; + row.style.top = index * ROW_HEIGHT + "px"; + row.style.left = "0"; + row.style.right = "0"; + row.style.height = ROW_HEIGHT + "px"; - const [iconName, iconColor] = rowIcon(entry); - const nameCol = entry.name === ".." ? "text-hint" : "text-primary"; + const [iconName, iconColor] = rowIcon(entry); + const nameCol = entry.name === ".." ? "text-hint" : "text-primary"; - row.innerHTML = ` -
- ${iconMarkup(iconName, 15)} - ${escapeHtml(entry.name)} -
-
${formatTime(entry.modified)}
-
${typeLabel(entry)}
-
${formatSize(entry.size)}
- `; + row.innerHTML = ` +
+ ${iconMarkup(iconName, 15)} + ${escapeHtml(entry.name)} +
+
${formatTime(entry.modified)}
+
${typeLabel(entry)}
+
${formatSize(entry.size)}
+ `; - 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("click", (e) => { + const opts = cache.opts; + if (entry.name === "..") { + opts.onSelect([], null); + return; + } + const idx = cache.selectableNames.indexOf(entry.name); + if (e.shiftKey && opts.anchor && cache.selectableNames.includes(opts.anchor)) { + const anchorIdx = cache.selectableNames.indexOf(opts.anchor); + const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx]; + opts.onSelect(cache.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(cache.selectableNames.filter((n) => next.has(n)), entry.name); + } else { + opts.onSelect([entry.name], entry.name); + } + }); + row.addEventListener("dblclick", () => cache.opts.onOpen(entry.name)); + + if (entry.name !== "..") { + row.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const opts = cache.opts; + let selection = opts.selected; + if (!selection.includes(entry.name)) { + selection = [entry.name]; + opts.onSelect(selection, entry.name); } + const selectedEntries = cache.sorted.filter((en) => selection.includes(en.name)); + const items = opts.contextMenuItems?.(entry, selectedEntries); + if (items) showContextMenu(e.clientX, e.clientY, items); }); - 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); + const payload = current.dragPayload?.(entry); + if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload); } - body.scrollTop = scrollPos; + if (entry.kind === "dir" || entry.name === "..") { + makeDropTarget(row, { + onDrop: (payload) => cache.opts.onDropOnEntry?.(entry, payload), + onHoverChange: (hover) => row.classList.toggle("drag-hover", hover), + onHoverHold: () => cache.opts.onHoverOpen?.(entry), + }); + } + + return row; +} + +function updateVisibleRows(cache) { + const sorted = cache.sorted; + if (sorted.length === 0) return; + + const { start, end } = computeVisibleRange( + cache.body.scrollTop, + cache.body.clientHeight, + sorted.length, + ); + + const existing = cache.spacer.querySelectorAll(".file-row"); + for (const row of existing) { + const idx = parseInt(row.dataset.index, 10); + if (idx < start || idx >= end) { + row.remove(); + } + } + + for (let i = start; i < end; i++) { + if (!cache.spacer.querySelector(`.file-row[data-index="${i}"]`)) { + const row = createRowElement(sorted[i], i, cache); + cache.spacer.appendChild(row); + } + } +} + +function updateSelection(cache, selected) { + const rows = cache.spacer.querySelectorAll(".file-row"); + for (const row of rows) { + const idx = parseInt(row.dataset.index, 10); + const entry = cache.sorted[idx]; + if (entry) { + row.classList.toggle("selected", selected.includes(entry.name)); + } + } +} + +export function renderFileTable(container, opts) { + const cache = initStructure(container); + cache.opts = opts; + + updateHeader(cache.header, opts); + + const sorted = sortEntries(opts.entries, opts.sort); + const sig = getSignature(sorted, opts.sort); + + if (sig !== cache.lastSignature) { + cache.lastSignature = sig; + cache.sorted = sorted; + cache.selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name); + cache.spacer.style.height = sorted.length * ROW_HEIGHT + "px"; + cache.body.scrollTop = 0; + cache.spacer.innerHTML = ""; + updateVisibleRows(cache); + } else { + updateSelection(cache, opts.selected); + } } diff --git a/frontend/js/components/paneLayout.js b/frontend/js/components/paneLayout.js index 2e959b9..5fb5fb5 100644 --- a/frontend/js/components/paneLayout.js +++ b/frontend/js/components/paneLayout.js @@ -64,7 +64,7 @@ function createPaneCell(pane) { const closeBtn = document.createElement("button"); closeBtn.className = "pane-close-btn"; closeBtn.title = "Close"; - closeBtn.innerHTML = iconMarkup("close", 10); + closeBtn.innerHTML = iconMarkup("close", 13); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); }); header.appendChild(closeBtn); } diff --git a/frontend/js/components/settingsDialog.js b/frontend/js/components/settingsDialog.js index 4d90902..e82a970 100644 --- a/frontend/js/components/settingsDialog.js +++ b/frontend/js/components/settingsDialog.js @@ -22,6 +22,7 @@ import { setLanguage, setTheme, } from "../settingsActions.js"; +import { openSiteManager } from "./siteManager.js"; const LANG_OPTIONS = [ ["en", "English"], @@ -176,6 +177,7 @@ function sectionConnections() {
${escapeHtml(s.name)}
${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}
+
`, ) @@ -348,7 +350,7 @@ function build() {
${iconMarkup("settings", 16)} ${t("settings.title")} - +
@@ -408,6 +410,11 @@ function wireContentActions(root) { }); }); + root.querySelectorAll("[data-action='edit-site']").forEach((btn) => { + btn.addEventListener("click", () => { + openSiteManager(btn.dataset.id); + }); + }); root.querySelectorAll("[data-action='delete-site']").forEach((btn) => { btn.addEventListener("click", async () => { await deleteSite(btn.dataset.id); diff --git a/frontend/js/components/siteManager.js b/frontend/js/components/siteManager.js new file mode 100644 index 0000000..258e45a --- /dev/null +++ b/frontend/js/components/siteManager.js @@ -0,0 +1,374 @@ +import { state, notify } from "../store.js"; +import { t } from "../i18n.js"; +import { iconMarkup } from "../icons.js"; +import { escapeHtml } from "../dom.js"; +import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js"; +import * as ipc from "../ipc.js"; + +const PROTOCOLS = ["sftp", "ftp", "ftps"]; +const DEFAULT_PORTS = { sftp: 22, ftp: 21, ftps: 990 }; + +let overlayEl = null; +let viewMode = "list"; +let form = emptyForm(); +let editingId = null; +let saving = false; +let deleteConfirmId = null; + +function emptyForm() { + return { + name: "", + protocol: "sftp", + host: "", + port: "", + username: "", + password: "", + keyPath: "", + folder: "", + note: "", + }; +} + +export function openSiteManager(siteId) { + const site = state.sites.find((s) => s.id === siteId); + if (!site) return; + editingId = siteId; + form = { + name: site.name, + protocol: site.protocol, + host: site.host, + port: String(site.port), + username: site.username, + password: site.password ?? "", + keyPath: site.keyPath ?? "", + folder: site.folder ?? "", + note: site.note ?? "", + }; + viewMode = "form"; + saving = false; + deleteConfirmId = null; + state.showSettingsDialog = false; + state.showSiteManager = true; + notify(); +} + +export function openSiteManagerNew() { + editingId = null; + form = emptyForm(); + viewMode = "list"; + saving = false; + deleteConfirmId = null; + state.showSiteManager = true; + notify(); +} + +function close() { + state.showSiteManager = false; + overlayEl?.remove(); + overlayEl = null; + notify(); +} + +function switchToList() { + viewMode = "list"; + deleteConfirmId = null; + notify(); +} + +async function saveCurrent() { + if (saving) return; + const name = form.name.trim(); + const host = form.host.trim(); + const username = form.username.trim(); + if (!name || !host || !username) return; + + let port = DEFAULT_PORTS[form.protocol]; + if (form.port.trim()) { + const parsed = Number(form.port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return; + port = parsed; + } + + saving = true; + notify(); + + const site = { + id: editingId || crypto.randomUUID(), + name, + protocol: form.protocol, + host, + port, + username, + password: form.password || null, + keyPath: form.keyPath || null, + folder: form.folder || null, + note: form.note || null, + }; + + try { + await ipc.saveSite(site); + state.sites = await ipc.listSites(); + state.statusMessage = t("siteManager.saved"); + viewMode = "list"; + saving = false; + notify(); + } catch { + saving = false; + notify(); + } +} + +async function deleteSiteAction(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(); +} + +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.style.width = "440px"; + dialog.addEventListener("mousedown", (e) => e.stopPropagation()); + dialog.addEventListener("keydown", (e) => { + if (e.key === "Enter" && viewMode === "form") saveCurrent(); + }); + + dialog.innerHTML = ` +
+ ${iconMarkup("serverSquare", 16)} + ${t("siteManager.title")} + +
+
+ `; + + dialog.querySelector('[data-action="close"]').addEventListener("click", close); + + overlay.appendChild(dialog); + return overlay; +} + +function renderList() { + const title = overlayEl.querySelector('[data-role="title"]'); + title.textContent = t("siteManager.title"); + + const body = overlayEl.querySelector('[data-role="body"]'); + + let html = ""; + if (!state.sites.length) { + html += `
${t("siteManager.noSites")}
`; + } else { + html += state.sites.map( + (s) => ` +
+
+
+ ${PROTOCOL_LABELS[s.protocol] ?? s.protocol} + ${escapeHtml(s.name)} +
+
${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}
+
+
+ + + +
+
`, + ).join(""); + } + + html += ` +
+ +
+ `; + + body.innerHTML = html; + + body.querySelectorAll("[data-action='sm-connect']").forEach((btn) => { + btn.addEventListener("click", async () => { + const site = state.sites.find((s) => s.id === btn.dataset.id); + if (site) { + close(); + await connectFromSite(site); + } + }); + }); + + body.querySelectorAll("[data-action='sm-edit']").forEach((btn) => { + btn.addEventListener("click", () => openSiteManager(btn.dataset.id)); + }); + + body.querySelectorAll("[data-action='sm-delete']").forEach((btn) => { + btn.addEventListener("click", async () => { + await deleteSiteAction(btn.dataset.id); + if (overlayEl) renderList(); + }); + }); + + body.querySelector("[data-action='sm-add']").addEventListener("click", () => { + editingId = null; + form = emptyForm(); + viewMode = "form"; + notify(); + }); +} + +function renderForm() { + const title = overlayEl.querySelector('[data-role="title"]'); + title.textContent = editingId ? t("siteManager.editSite") : t("siteManager.addSite"); + + const body = overlayEl.querySelector('[data-role="body"]'); + + body.innerHTML = ` +
+ ${t("siteManager.name")} + +
+ +
+ ${t("siteManager.protocol")} +
+ ${PROTOCOLS.map((p) => ``).join("")} +
+
+ +
+ ${t("connect.host")} + +
+ +
+
+ ${t("connect.username")} + +
+
+ ${t("connect.port")} + +
+
+ +
+ ${t("connect.password")} +
+ + +
+
+ +
+

${t("siteManager.keyHint")}

+
+ ${t("connect.keyFile")} +
+ + +
+
+
+ +
+ ${t("siteManager.folder")} + +
+ +
+ ${t("siteManager.note")} + +
+ + + `; + + body.querySelector('[data-action="cancel"]').addEventListener("click", switchToList); + body.querySelector('[data-action="save"]').addEventListener("click", saveCurrent); + + body.querySelectorAll("[data-field]").forEach((input) => { + input.addEventListener("input", () => { + form[input.dataset.field] = input.value; + }); + }); + + const passInput = body.querySelector('[data-field="password"]'); + const toggleBtn = body.querySelector('[data-action="toggle-pass"]'); + if (toggleBtn && passInput) { + toggleBtn.addEventListener("click", () => { + const showing = passInput.type === "text"; + passInput.type = showing ? "password" : "text"; + toggleBtn.innerHTML = iconMarkup(showing ? "eye" : "eyeClosed", 15); + }); + } + + const browseBtn = body.querySelector('[data-action="browse"]'); + if (browseBtn) browseBtn.addEventListener("click", browseKeyFile); + + body.querySelectorAll("[data-protocol]").forEach((btn) => { + btn.addEventListener("click", () => { + const proto = btn.dataset.protocol; + form.protocol = proto; + const portInput = body.querySelector('[data-field="port"]'); + if (!form.port) { + portInput.placeholder = String(DEFAULT_PORTS[proto]); + } + body.querySelectorAll("[data-protocol]").forEach((b) => b.classList.toggle("active", b === btn)); + const keySection = body.querySelector('[data-role="key-section"]'); + if (keySection) keySection.style.display = proto === "sftp" ? "block" : "none"; + }); + }); + + setTimeout(() => body.querySelector('[data-field="name"]')?.focus(), 0); +} + +export function renderSiteManager() { + if (!state.showSiteManager) { + if (overlayEl) { + overlayEl.remove(); + overlayEl = null; + } + return; + } + + if (!overlayEl) { + overlayEl = build(); + document.body.appendChild(overlayEl); + } + + if (viewMode === "list") { + renderList(); + } else { + renderForm(); + } +} diff --git a/frontend/js/components/tabs.js b/frontend/js/components/tabs.js index 07ff07a..07d1ee3 100644 --- a/frontend/js/components/tabs.js +++ b/frontend/js/components/tabs.js @@ -65,7 +65,7 @@ function createTab(id, label, status, closable) { const close = document.createElement("button"); close.type = "button"; close.className = "tab-close"; - close.innerHTML = iconMarkup("close", 10); + close.innerHTML = iconMarkup("close", 13); close.title = "Close"; close.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/frontend/js/components/toolbar.js b/frontend/js/components/toolbar.js index 5dade7a..4cbb010 100644 --- a/frontend/js/components/toolbar.js +++ b/frontend/js/components/toolbar.js @@ -1,6 +1,7 @@ import { state, notify, activeTab } from "../store.js"; import { t } from "../i18n.js"; import { iconMarkup } from "../icons.js"; +import * as ipc from "../ipc.js"; import { openNew } from "./connectDialog.js"; import { toggle as toggleHistory } from "./historyPopup.js"; import { openMkdir, openDelete, openRename } from "./opDialogs.js"; @@ -49,6 +50,7 @@ export function renderToolbar(container) { +
@@ -90,6 +92,9 @@ export function renderToolbar(container) { state.opTarget = state.activePane; openRename(names[0]); }); + container.querySelector('[data-action="new-window"]').addEventListener("click", () => { + ipc.newWindow(); + }); container.querySelector('[data-action="history"]').addEventListener("click", (e) => { toggleHistory(e.currentTarget); }); diff --git a/frontend/js/components/welcome.js b/frontend/js/components/welcome.js index e8aa64f..60c5185 100644 --- a/frontend/js/components/welcome.js +++ b/frontend/js/components/welcome.js @@ -4,6 +4,7 @@ import { iconMarkup } from "../icons.js"; import { renderHistoryRows } from "./historyList.js"; import { openNew } from "./connectDialog.js"; import { openSettings } from "./settingsDialog.js"; +import { openSiteManagerNew } from "./siteManager.js"; import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js"; import { escapeHtml } from "../dom.js"; import { shortcutLabel } from "../platform.js"; @@ -31,6 +32,9 @@ export function renderWelcome(container) {