diff --git a/.cargo/config.toml b/.cargo/config.toml index 8b13789..39dc687 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1 +1,2 @@ - +[build] +jobs = 6 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a767691..64560bb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -3,7 +3,6 @@ name: Rust on: push: branches: [ "main" ] - tags: [ "v*" ] pull_request: branches: [ "main" ] @@ -59,10 +58,6 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build (release) run: cargo build --release - - uses: actions/upload-artifact@v4 - with: - name: wherry-x86_64 - path: target/release/wherry test: runs-on: ubuntu-latest @@ -86,27 +81,3 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run tests run: cargo test - - release: - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - needs: [build, test] - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: wherry-x86_64 - - name: Package binary - run: | - mkdir -p pkg/usr/bin pkg/usr/share/licenses/wherry pkg/usr/share/pixmaps pkg/usr/share/applications - install -Dm755 wherry pkg/usr/bin/wherry - install -Dm644 LICENSE pkg/usr/share/licenses/wherry/LICENSE - install -Dm644 src/icons/128x128.png pkg/usr/share/pixmaps/wherry.png - install -Dm644 dist/wherry.desktop pkg/usr/share/applications/wherry.desktop - tar czf wherry-${{ github.ref_name }}-x86_64-linux.tar.gz -C pkg . - - uses: softprops/action-gh-release@v2 - with: - files: wherry-${{ github.ref_name }}-x86_64-linux.tar.gz - generate_release_notes: true diff --git a/LICENSE b/LICENSE index f288702..162ee6c 100644 --- a/LICENSE +++ b/LICENSE @@ -392,8 +392,13 @@ governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms +<<<<<<< HEAD of that license document, provided that the further restriction does not survive such relicensing or conveying. +======= +of that license document, provided that the further restriction does not +survive such relicensing or conveying. +>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client) If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the @@ -511,12 +516,20 @@ in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or +<<<<<<< HEAD arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. +======= +arrangement, you convey, or procure by conveying, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of +the covered work, then the patent license you grant is automatically +extended to all recipients of the covered work and works based on it. +>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client) A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is @@ -534,8 +547,13 @@ contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting +<<<<<<< HEAD any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. +======= +any implied license or other defenses to infringement that may be +otherwise available to you under applicable patent law. +>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client) 12. No Surrender of Others' Freedom. @@ -576,7 +594,11 @@ Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. +<<<<<<< HEAD If the Program specifies that a proxy can decide which future +======= + If the Program specifies that a proxy can decide whether future +>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client) versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. diff --git a/dist/.SRCINFO b/dist/.SRCINFO index 25a2590..534f91d 100644 --- a/dist/.SRCINFO +++ b/dist/.SRCINFO @@ -1,12 +1,13 @@ pkgbase = wherry pkgdesc = A modern dual-pane file manager with SFTP/FTP/FTPS support pkgver = 0.1.0 - pkgrel = 2 + pkgrel = 1 url = https://github.com/loki5512344/Wherry arch = x86_64 license = GPL3 makedepends = cargo makedepends = pkg-config + makedepends = patchelf depends = gcc-libs depends = gtk3 depends = webkit2gtk-4.1 @@ -17,9 +18,9 @@ pkgbase = wherry depends = librsvg depends = openssl depends = libssh2 + depends = libappindicator-gtk3 depends = dbus - optdepends = mold: faster linking source = https://github.com/loki5512344/Wherry/archive/refs/tags/v0.1.0.tar.gz - sha256sums = 3cebca09e29629f4edb56a42074cad9b1f2539fc64cf3e96c3e7208dbacff1ec + sha256sums = SKIP pkgname = wherry diff --git a/dist/PKGBUILD b/dist/PKGBUILD index 876e2cd..2b94a3d 100644 --- a/dist/PKGBUILD +++ b/dist/PKGBUILD @@ -1,8 +1,8 @@ -# Maintainer: loki5512344 +# Maintainer: loki5512344 pkgname=wherry pkgver=0.1.0 -pkgrel=2 +pkgrel=1 pkgdesc="A modern dual-pane file manager with SFTP/FTP/FTPS support" arch=('x86_64') url='https://github.com/loki5512344/Wherry' @@ -18,12 +18,12 @@ depends=( 'librsvg' 'openssl' 'libssh2' + 'libappindicator-gtk3' 'dbus' ) -makedepends=('cargo' 'pkg-config') -optdepends=('mold: faster linking') +makedepends=('cargo' 'pkg-config' 'patchelf') source=("https://github.com/loki5512344/Wherry/archive/refs/tags/v$pkgver.tar.gz") -sha256sums=('3cebca09e29629f4edb56a42074cad9b1f2539fc64cf3e96c3e7208dbacff1ec') +sha256sums=('SKIP') _repodir="Wherry-$pkgver" @@ -36,6 +36,7 @@ prepare() { build() { cd "$srcdir/$_repodir" export CARGO_TARGET_DIR="$srcdir/target" + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="/usr/bin/cc" cargo build --release --frozen } diff --git a/dist/wherry-bin/.SRCINFO b/dist/wherry-bin/.SRCINFO deleted file mode 100644 index d640799..0000000 --- a/dist/wherry-bin/.SRCINFO +++ /dev/null @@ -1,22 +0,0 @@ -pkgbase = wherry-bin - pkgdesc = A modern dual-pane file manager with SFTP/FTP/FTPS support (pre-compiled) - pkgver = 0.1.0 - pkgrel = 1 - url = https://github.com/loki5512344/Wherry - arch = x86_64 - license = GPL3 - depends = gcc-libs - depends = gtk3 - depends = webkit2gtk-4.1 - depends = libsoup3 - depends = cairo - depends = gdk-pixbuf2 - depends = glib2 - depends = librsvg - depends = openssl - depends = libssh2 - depends = dbus - source = https://github.com/loki5512344/Wherry/releases/download/v0.1.0/wherry-v0.1.0-x86_64-linux.tar.gz - sha256sums = d00bedf0ea2890a18d5c506880a33f4bd87eeec2228309279a3d06b1657a7b5e - -pkgname = wherry-bin diff --git a/dist/wherry-bin/PKGBUILD b/dist/wherry-bin/PKGBUILD deleted file mode 100644 index 6846021..0000000 --- a/dist/wherry-bin/PKGBUILD +++ /dev/null @@ -1,28 +0,0 @@ -# Maintainer: loki5512344 - -pkgname=wherry-bin -pkgver=0.1.0 -pkgrel=1 -pkgdesc="A modern dual-pane file manager with SFTP/FTP/FTPS support (pre-compiled)" -arch=('x86_64') -url='https://github.com/loki5512344/Wherry' -license=('GPL3') -depends=( - 'gcc-libs' - 'gtk3' - 'webkit2gtk-4.1' - 'libsoup3' - 'cairo' - 'gdk-pixbuf2' - 'glib2' - 'librsvg' - 'openssl' - 'libssh2' - 'dbus' -) -source=("https://github.com/loki5512344/Wherry/releases/download/v$pkgver/wherry-v$pkgver-x86_64-linux.tar.gz") -sha256sums=('d00bedf0ea2890a18d5c506880a33f4bd87eeec2228309279a3d06b1657a7b5e') - -package() { - cp -r "$srcdir"/* "$pkgdir"/ -} diff --git a/frontend/css/layout.css b/frontend/css/layout.css index 2b87b12..d2d47c2 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -649,42 +649,8 @@ html[data-platform="linux"] ::-webkit-scrollbar { height: 7px; } -/* ── Pane resize handle ──────────────────────────────────────────────── */ -.pane-resize-handle { - width: 5px; - cursor: col-resize; - background: transparent; - position: relative; - z-index: 10; - flex-shrink: 0; - margin: 0 -2px; - transition: background 0.12s var(--ease-out); -} -.pane-resize-handle:hover, -.pane-resize-handle:active { - background: var(--accent-soft); -} -.pane-resize-handle::after { - content: ""; - position: absolute; - left: 2px; - top: 50%; - transform: translateY(-50%); - width: 1px; - height: 36px; - background: var(--border); - border-radius: 1px; - transition: background 0.12s var(--ease-out), height 0.12s var(--ease-out); -} -.pane-resize-handle:hover::after, -.pane-resize-handle:active::after { - background: var(--accent); - height: 48px; -} - /* ── Split-pane grid ─────────────────────────────────────────────────── */ .pane-grid { - display: grid; flex: 1; min-height: 0; } @@ -697,6 +663,9 @@ html[data-platform="linux"] ::-webkit-scrollbar { 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); diff --git a/frontend/js/components/paneLayout.js b/frontend/js/components/paneLayout.js index 76a4f9a..5fb5fb5 100644 --- a/frontend/js/components/paneLayout.js +++ b/frontend/js/components/paneLayout.js @@ -12,8 +12,6 @@ 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 ?? "?"); } -function ensureFlex(p) { if (p.flex == null) p.flex = 1; return p; } - /* Render the full pane grid */ export function renderPanes(container) { container.innerHTML = ""; @@ -21,9 +19,10 @@ export function renderPanes(container) { const wrap = document.createElement("div"); wrap.className = "pane-grid"; - applyGrid(wrap); + 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; @@ -32,91 +31,13 @@ export function renderPanes(container) { }); container.appendChild(wrap); - for (let i = 0; i < state.layout.panes.length; i++) { - if (i > 0) { - const handle = createResizeHandle(i - 1); - wrap.appendChild(handle); - } - const cell = createPaneCell(state.layout.panes[i]); + for (const pane of state.layout.panes) { + const cell = createPaneCell(pane); wrap.appendChild(cell); } } -function applyGrid(wrap) { - const n = state.layout.panes.length; - const totalFlex = state.layout.panes.reduce((s, p) => s + (p.flex || 1), 0); - const cols = state.layout.panes.map((p) => `${((p.flex || 1) / totalFlex * 100).toFixed(2)}fr`).join(" "); - wrap.style.cssText = `display:grid;grid-template-columns:${n > 1 ? cols : "1fr"};flex:1;min-height:0;`; -} - -function createResizeHandle(index) { - const div = document.createElement("div"); - div.className = "pane-resize-handle"; - div.dataset.index = index; - div.addEventListener("mousedown", onResizeStart); - return div; -} - -let resizing = null; - -function onResizeStart(e) { - e.preventDefault(); - const handle = e.currentTarget; - const index = parseInt(handle.dataset.index); - const wrap = handle.closest(".pane-grid"); - const rect = wrap.getBoundingClientRect(); - const startX = e.clientX; - - const panes = state.layout.panes; - const totalFlex = panes.reduce((s, p) => s + (p.flex || 1), 0); - const currentPct = panes.map((p) => (p.flex || 1) / totalFlex); - - resizing = { index, startX, wrap, rect, panes, currentPct, totalFlex }; - - document.addEventListener("mousemove", onResizeMove); - document.addEventListener("mouseup", onResizeEnd); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; -} - -function onResizeMove(e) { - if (!resizing) return; - const { index, startX, rect, panes, currentPct } = resizing; - const dx = e.clientX - startX; - const totalWidth = rect.width; - if (totalWidth <= 0) return; - - const leftPct = currentPct[index]; - const rightPct = currentPct[index + 1]; - const pctChange = dx / totalWidth; - - const newLeft = Math.max(0.1, Math.min(0.9, leftPct + pctChange)); - const newRight = Math.max(0.1, Math.min(0.9, rightPct - pctChange)); - if (newLeft + newRight < 0.2) return; - - const newTotal = newLeft + newRight + currentPct.slice(0, index).reduce((s, v) => s + v, 0) + currentPct.slice(index + 2).reduce((s, v) => s + v, 0); - - const scale = currentPct.reduce((a, b) => a + b, 0) / newTotal; - for (let i = 0; i < panes.length; i++) { - if (i === index) panes[i].flex = newLeft * scale; - else if (i === index + 1) panes[i].flex = newRight * scale; - else panes[i].flex = currentPct[i] * scale; - } - - const wrap = resizing.wrap; - applyGrid(wrap); -} - -function onResizeEnd() { - resizing = null; - document.removeEventListener("mousemove", onResizeMove); - document.removeEventListener("mouseup", onResizeEnd); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; -} - function createPaneCell(pane) { - ensureFlex(pane); const cell = document.createElement("div"); cell.className = "pane-cell"; cell.dataset.paneId = pane.id; @@ -139,12 +60,14 @@ function createPaneCell(pane) { header.addEventListener("dragend", onHeaderDragEnd); // Close btn - const closeBtn = document.createElement("button"); - closeBtn.className = "pane-close-btn"; - closeBtn.title = "Close pane"; - closeBtn.innerHTML = iconMarkup("close", 13); - closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); }); - header.appendChild(closeBtn); + if (state.layout.panes.length > 1) { + const closeBtn = document.createElement("button"); + closeBtn.className = "pane-close-btn"; + closeBtn.title = "Close"; + closeBtn.innerHTML = iconMarkup("close", 13); + closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); }); + header.appendChild(closeBtn); + } // Body const body = document.createElement("div"); @@ -171,6 +94,12 @@ function createPaneCell(pane) { 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"); @@ -181,7 +110,7 @@ export function updatePanes(container) { const expected = state.layout.panes.map((p) => p.id); if (ids.join(",") !== expected.join(",")) return renderPanes(container); - applyGrid(wrap); + wrap.style.gridTemplateColumns = buildGrid(); for (const pane of state.layout.panes) { const ref = paneRefs.get(pane.id); @@ -198,10 +127,20 @@ export function updatePanes(container) { // ── Drag & Drop: tab bar → pane ───────────────────────────────────────── -let activeZone = null; -let holdTimer = null; -let holdPaneId = null; -let openPaneId = null; +/* 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; // "-" 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(); @@ -254,6 +193,10 @@ function onPaneDragOver(e) { 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; @@ -303,7 +246,7 @@ function onPaneDrop(e) { const wasOpen = openPaneId === paneId; const zone = cell.dataset.dropZone || "center"; resetPaneDrag(); - if (!tabId || !wasOpen) return; + if (!tabId || !wasOpen) return; // released before this pane "opened" -- ignore const idx = state.layout.panes.findIndex((p) => p.id === paneId); if (idx === -1) return; @@ -312,9 +255,9 @@ function onPaneDrop(e) { switch (zone) { case "left": addPane(tid, idx); break; case "right": addPane(tid, idx + 1); break; - case "top": addPane(tid, idx + 1); break; - case "bottom": addPane(tid, idx + 1); break; - case "center": + 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; } @@ -323,6 +266,8 @@ function onPaneDrop(e) { 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) ─────────────────────────────────────────────── @@ -347,28 +292,28 @@ function parsedTabId(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, flex: 1 }); + state.layout.panes.splice(index, 0, { id: `pane-${Date.now()}`, tabId }); } function closePane(paneId) { - const pane = state.layout.panes.find((p) => p.id === paneId); - if (!pane) return; - const isLastRemote = pane.tabId !== null && state.layout.panes.filter((p) => p.tabId !== null).length === 1; + if (state.layout.panes.length <= 1) return; state.layout.panes = state.layout.panes.filter((p) => p.id !== paneId); - if (state.layout.panes.length === 0) { - state.layout.panes.push({ id: `pane-${Date.now()}`, tabId: null, flex: 1 }); - } 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(); } diff --git a/frontend/js/connectActions.js b/frontend/js/connectActions.js index ab16401..34dc24b 100644 --- a/frontend/js/connectActions.js +++ b/frontend/js/connectActions.js @@ -59,13 +59,8 @@ export async function spawnConnect(params) { state.activeTabId = connectionId; state.lastRemoteTabId = connectionId; state.activePane = "remote"; - // Open dual-pane: local (left) + remote (right) - if (state.layout.panes.length === 1 && state.layout.panes[0].tabId === null) { - state.layout.panes.push({ id: `pane-${Date.now()}`, tabId: connectionId, flex: 1 }); - state.layout.panes[0].flex = 1; - } else { - state.layout.panes[0].tabId = connectionId; - } + // 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}`; diff --git a/src/commands.rs b/src/commands.rs index f15d893..7ca16a3 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -501,7 +501,7 @@ pub async fn new_window( let app_handle = app_handle.clone(); let label_clone = label.clone(); window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { .. } = event + if let tauri::WindowEvent::CloseRequested { api: _, .. } = event && let Some(win) = app_handle.get_webview_window(&label_clone) { save_window_state_internal(&win, &db); diff --git a/src/lib.rs b/src/lib.rs index e9af10f..b070e1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -99,7 +99,7 @@ pub fn run() { let handle2 = handle.clone(); let label2 = label.clone(); window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { .. } = event + if let tauri::WindowEvent::CloseRequested { api: _, .. } = event && let Some(win) = handle2.get_webview_window(&label2) { commands::save_window_state_internal(&win, &db2);