Compare commits

..

No commits in common. "a617fbfadf7f14496e72846342c46a825e230da9" and "cf49e46a6d4f06d805ecedee7d206f36b597c35f" have entirely different histories.

12 changed files with 91 additions and 236 deletions

View file

@ -1 +1,2 @@
[build]
jobs = 6

View file

@ -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

22
LICENSE
View file

@ -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.

7
dist/.SRCINFO vendored
View file

@ -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

11
dist/PKGBUILD vendored
View file

@ -1,8 +1,8 @@
# Maintainer: loki5512344 <mauhhhh7@gmail.com>
# Maintainer: loki5512344 <dev14dbq@gmail.com>
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
}

View file

@ -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

View file

@ -1,28 +0,0 @@
# Maintainer: loki5512344 <mauhhhh7@gmail.com>
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"/
}

View file

@ -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);

View file

@ -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; // "<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();
@ -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();
}

View file

@ -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}`;

View file

@ -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);

View file

@ -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);