Compare commits

..

10 commits

Author SHA1 Message Date
a617fbfadf
update sha256sums for v0.1.0
Some checks are pending
Rust / lint (push) Waiting to run
Rust / build (push) Blocked by required conditions
Rust / test (push) Blocked by required conditions
Rust / release (push) Blocked by required conditions
2026-07-14 15:02:29 +02:00
b2413e5e5a
feat: auto dual-pane on connect, add resize handles, always show close btn 2026-07-14 14:48:12 +02:00
7ced3bc24f
fix: update maintainer email 2026-07-14 14:30:30 +02:00
0276e5692e
fix CI: remove double v in release filename 2026-07-14 14:23:56 +02:00
f22e0cb701
fix CI: add contents:write permission for release 2026-07-14 14:16:20 +02:00
c32c3a2e2d
feat: add release workflow, wherry-bin PKGBUILD, mold optdepends 2026-07-14 14:09:47 +02:00
88260d7ea2
fix AUR: remove jobs limit, drop explicit linker 2026-07-14 14:06:44 +02:00
4a4adb1ae5
update sha256sum for v0.1.0 2026-07-14 13:53:57 +02:00
96a4598e47
fix: resolve LICENSE merge conflicts, update AUR PKGBUILD 2026-07-14 13:51:55 +02:00
e20447dbc4
fix clippy: remove redundant api:_ wildcard in CloseRequested pattern 2026-07-11 12:57:31 +02:00
12 changed files with 236 additions and 91 deletions

View file

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

View file

@ -3,6 +3,7 @@ name: Rust
on: on:
push: push:
branches: [ "main" ] branches: [ "main" ]
tags: [ "v*" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
@ -58,6 +59,10 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build (release) - name: Build (release)
run: cargo build --release run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: wherry-x86_64
path: target/release/wherry
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -81,3 +86,27 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Run tests - name: Run tests
run: cargo test 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,13 +392,8 @@ governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms License, you may add to a covered work material governed by the terms
<<<<<<< HEAD
of that license document, provided that the further restriction does of that license document, provided that the further restriction does
not survive such relicensing or conveying. 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 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 must place, in the relevant source files, a statement of the
@ -516,20 +511,12 @@ in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid. country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or If, pursuant to or in connection with a single transaction or
<<<<<<< HEAD
arrangement, you convey, or propagate by procuring conveyance of, a arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered you grant is automatically extended to all recipients of the covered
work and works based on it. 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 A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is the scope of its coverage, prohibits the exercise of, or is
@ -547,13 +534,8 @@ contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007. or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting Nothing in this License shall be construed as excluding or limiting
<<<<<<< HEAD
any implied license or other defenses to infringement that may any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law. 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. 12. No Surrender of Others' Freedom.
@ -594,11 +576,7 @@ Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published GNU General Public License, you may choose any version ever published
by the Free Software Foundation. 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 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 versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.

7
dist/.SRCINFO vendored
View file

@ -1,13 +1,12 @@
pkgbase = wherry pkgbase = wherry
pkgdesc = A modern dual-pane file manager with SFTP/FTP/FTPS support pkgdesc = A modern dual-pane file manager with SFTP/FTP/FTPS support
pkgver = 0.1.0 pkgver = 0.1.0
pkgrel = 1 pkgrel = 2
url = https://github.com/loki5512344/Wherry url = https://github.com/loki5512344/Wherry
arch = x86_64 arch = x86_64
license = GPL3 license = GPL3
makedepends = cargo makedepends = cargo
makedepends = pkg-config makedepends = pkg-config
makedepends = patchelf
depends = gcc-libs depends = gcc-libs
depends = gtk3 depends = gtk3
depends = webkit2gtk-4.1 depends = webkit2gtk-4.1
@ -18,9 +17,9 @@ pkgbase = wherry
depends = librsvg depends = librsvg
depends = openssl depends = openssl
depends = libssh2 depends = libssh2
depends = libappindicator-gtk3
depends = dbus depends = dbus
optdepends = mold: faster linking
source = https://github.com/loki5512344/Wherry/archive/refs/tags/v0.1.0.tar.gz source = https://github.com/loki5512344/Wherry/archive/refs/tags/v0.1.0.tar.gz
sha256sums = SKIP sha256sums = 3cebca09e29629f4edb56a42074cad9b1f2539fc64cf3e96c3e7208dbacff1ec
pkgname = wherry pkgname = wherry

11
dist/PKGBUILD vendored
View file

@ -1,8 +1,8 @@
# Maintainer: loki5512344 <dev14dbq@gmail.com> # Maintainer: loki5512344 <mauhhhh7@gmail.com>
pkgname=wherry pkgname=wherry
pkgver=0.1.0 pkgver=0.1.0
pkgrel=1 pkgrel=2
pkgdesc="A modern dual-pane file manager with SFTP/FTP/FTPS support" pkgdesc="A modern dual-pane file manager with SFTP/FTP/FTPS support"
arch=('x86_64') arch=('x86_64')
url='https://github.com/loki5512344/Wherry' url='https://github.com/loki5512344/Wherry'
@ -18,12 +18,12 @@ depends=(
'librsvg' 'librsvg'
'openssl' 'openssl'
'libssh2' 'libssh2'
'libappindicator-gtk3'
'dbus' 'dbus'
) )
makedepends=('cargo' 'pkg-config' 'patchelf') makedepends=('cargo' 'pkg-config')
optdepends=('mold: faster linking')
source=("https://github.com/loki5512344/Wherry/archive/refs/tags/v$pkgver.tar.gz") source=("https://github.com/loki5512344/Wherry/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP') sha256sums=('3cebca09e29629f4edb56a42074cad9b1f2539fc64cf3e96c3e7208dbacff1ec')
_repodir="Wherry-$pkgver" _repodir="Wherry-$pkgver"
@ -36,7 +36,6 @@ prepare() {
build() { build() {
cd "$srcdir/$_repodir" cd "$srcdir/$_repodir"
export CARGO_TARGET_DIR="$srcdir/target" export CARGO_TARGET_DIR="$srcdir/target"
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="/usr/bin/cc"
cargo build --release --frozen cargo build --release --frozen
} }

22
dist/wherry-bin/.SRCINFO vendored Normal file
View file

@ -0,0 +1,22 @@
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

28
dist/wherry-bin/PKGBUILD vendored Normal file
View file

@ -0,0 +1,28 @@
# 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,8 +649,42 @@ html[data-platform="linux"] ::-webkit-scrollbar {
height: 7px; 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 ─────────────────────────────────────────────────── */ /* ── Split-pane grid ─────────────────────────────────────────────────── */
.pane-grid { .pane-grid {
display: grid;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
@ -663,9 +697,6 @@ html[data-platform="linux"] ::-webkit-scrollbar {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.pane-cell + .pane-cell {
border-left: 1px solid var(--border);
}
.pane-cell.drop-zone { .pane-cell.drop-zone {
background: var(--bg-panel); background: var(--bg-panel);
outline: 2px solid var(--accent); outline: 2px solid var(--accent);

View file

@ -12,6 +12,8 @@ const paneRefs = new Map();
function paneKind(p) { return p.tabId ?? "local"; } 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 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 */ /* Render the full pane grid */
export function renderPanes(container) { export function renderPanes(container) {
container.innerHTML = ""; container.innerHTML = "";
@ -19,10 +21,9 @@ export function renderPanes(container) {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "pane-grid"; wrap.className = "pane-grid";
wrap.style.cssText = `display:grid;grid-template-columns:${buildGrid()};flex:1;min-height:0;`; applyGrid(wrap);
wrap.addEventListener("dragover", (e) => { e.preventDefault(); }); wrap.addEventListener("dragover", (e) => { e.preventDefault(); });
wrap.addEventListener("drop", (e) => { wrap.addEventListener("drop", (e) => {
// Drop on empty area (after last pane) → append
e.preventDefault(); e.preventDefault();
const tabId = e.dataTransfer.getData("tab-id"); const tabId = e.dataTransfer.getData("tab-id");
if (!tabId) return; if (!tabId) return;
@ -31,13 +32,91 @@ export function renderPanes(container) {
}); });
container.appendChild(wrap); container.appendChild(wrap);
for (const pane of state.layout.panes) { for (let i = 0; i < state.layout.panes.length; i++) {
const cell = createPaneCell(pane); if (i > 0) {
const handle = createResizeHandle(i - 1);
wrap.appendChild(handle);
}
const cell = createPaneCell(state.layout.panes[i]);
wrap.appendChild(cell); 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) { function createPaneCell(pane) {
ensureFlex(pane);
const cell = document.createElement("div"); const cell = document.createElement("div");
cell.className = "pane-cell"; cell.className = "pane-cell";
cell.dataset.paneId = pane.id; cell.dataset.paneId = pane.id;
@ -60,14 +139,12 @@ function createPaneCell(pane) {
header.addEventListener("dragend", onHeaderDragEnd); header.addEventListener("dragend", onHeaderDragEnd);
// Close btn // Close btn
if (state.layout.panes.length > 1) { const closeBtn = document.createElement("button");
const closeBtn = document.createElement("button"); closeBtn.className = "pane-close-btn";
closeBtn.className = "pane-close-btn"; closeBtn.title = "Close pane";
closeBtn.title = "Close"; closeBtn.innerHTML = iconMarkup("close", 13);
closeBtn.innerHTML = iconMarkup("close", 13); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); }); header.appendChild(closeBtn);
header.appendChild(closeBtn);
}
// Body // Body
const body = document.createElement("div"); const body = document.createElement("div");
@ -94,12 +171,6 @@ function createPaneCell(pane) {
return cell; 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) */ /* In-place update (avoids full rebuild on every tick) */
export function updatePanes(container) { export function updatePanes(container) {
const wrap = container.querySelector(".pane-grid"); const wrap = container.querySelector(".pane-grid");
@ -110,7 +181,7 @@ export function updatePanes(container) {
const expected = state.layout.panes.map((p) => p.id); const expected = state.layout.panes.map((p) => p.id);
if (ids.join(",") !== expected.join(",")) return renderPanes(container); if (ids.join(",") !== expected.join(",")) return renderPanes(container);
wrap.style.gridTemplateColumns = buildGrid(); applyGrid(wrap);
for (const pane of state.layout.panes) { for (const pane of state.layout.panes) {
const ref = paneRefs.get(pane.id); const ref = paneRefs.get(pane.id);
@ -127,20 +198,10 @@ export function updatePanes(container) {
// ── Drag & Drop: tab bar → pane ───────────────────────────────────────── // ── Drag & Drop: tab bar → pane ─────────────────────────────────────────
/* Tab bar tabs become draggable — set data "tab-id" on dragstart. Pane cells let activeZone = null;
get visual drop zones (left/right/top/bottom/center), but two rules keep let holdTimer = null;
it from feeling random: let holdPaneId = null;
1. The pane already showing the dragged tab never offers itself as a let openPaneId = null;
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) { function computeZone(cell, clientX, clientY) {
const rect = cell.getBoundingClientRect(); const rect = cell.getBoundingClientRect();
@ -193,10 +254,6 @@ function onPaneDragOver(e) {
const paneId = cell.dataset.paneId; const paneId = cell.dataset.paneId;
const pane = state.layout.panes.find((p) => p.id === 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()) { if (pane && pane.tabId === getDraggedTabId()) {
e.stopPropagation(); e.stopPropagation();
return; return;
@ -246,7 +303,7 @@ function onPaneDrop(e) {
const wasOpen = openPaneId === paneId; const wasOpen = openPaneId === paneId;
const zone = cell.dataset.dropZone || "center"; const zone = cell.dataset.dropZone || "center";
resetPaneDrag(); resetPaneDrag();
if (!tabId || !wasOpen) return; // released before this pane "opened" -- ignore if (!tabId || !wasOpen) return;
const idx = state.layout.panes.findIndex((p) => p.id === paneId); const idx = state.layout.panes.findIndex((p) => p.id === paneId);
if (idx === -1) return; if (idx === -1) return;
@ -255,9 +312,9 @@ function onPaneDrop(e) {
switch (zone) { switch (zone) {
case "left": addPane(tid, idx); break; case "left": addPane(tid, idx); break;
case "right": addPane(tid, idx + 1); break; case "right": addPane(tid, idx + 1); break;
case "top": addPane(tid, idx + 1); break; // in flat layout, left/right suffices case "top": addPane(tid, idx + 1); break;
case "bottom": addPane(tid, idx + 1); break; // top/bottom = same as right case "bottom": addPane(tid, idx + 1); break;
case "center": // replace this pane (but don't add duplicate) case "center":
if (!state.layout.panes.some((p) => p.tabId === tid && p.id !== paneId)) { if (!state.layout.panes.some((p) => p.tabId === tid && p.id !== paneId)) {
state.layout.panes[idx].tabId = tid; state.layout.panes[idx].tabId = tid;
} }
@ -266,8 +323,6 @@ function onPaneDrop(e) {
notify(); 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); window.addEventListener("dragend", resetPaneDrag);
// ── Header drag (reorder) ─────────────────────────────────────────────── // ── Header drag (reorder) ───────────────────────────────────────────────
@ -292,28 +347,28 @@ function parsedTabId(raw) {
} }
function addPane(tabId, index) { function addPane(tabId, index) {
// If this tab is already in another pane, remove it
const existing = state.layout.panes.findIndex((p) => p.tabId === tabId); const existing = state.layout.panes.findIndex((p) => p.tabId === tabId);
if (existing !== -1) { if (existing !== -1) {
state.layout.panes.splice(existing, 1); state.layout.panes.splice(existing, 1);
if (existing < index) index--; if (existing < index) index--;
} }
state.layout.panes.splice(index, 0, { id: `pane-${Date.now()}`, tabId }); state.layout.panes.splice(index, 0, { id: `pane-${Date.now()}`, tabId, flex: 1 });
} }
function closePane(paneId) { function closePane(paneId) {
if (state.layout.panes.length <= 1) return; 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;
state.layout.panes = state.layout.panes.filter((p) => p.id !== paneId); 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(); notify();
} }
// ── Public API: open a tab in a pane from outside ─────────────────────── // ── Public API: open a tab in a pane from outside ───────────────────────
export function openInPane(tabId) { 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; 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; state.layout.panes[0].tabId = tabId;
notify(); notify();
} }

View file

@ -59,8 +59,13 @@ export async function spawnConnect(params) {
state.activeTabId = connectionId; state.activeTabId = connectionId;
state.lastRemoteTabId = connectionId; state.lastRemoteTabId = connectionId;
state.activePane = "remote"; state.activePane = "remote";
// Replace first pane with new connection (click = switch, not split) // Open dual-pane: local (left) + remote (right)
state.layout.panes[0].tabId = connectionId; 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;
}
state.connectLoading = false; state.connectLoading = false;
state.showConnectDialog = false; state.showConnectDialog = false;
state.statusMessage = `Connected to ${params.host}`; state.statusMessage = `Connected to ${params.host}`;

View file

@ -501,7 +501,7 @@ pub async fn new_window(
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
let label_clone = label.clone(); let label_clone = label.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: _, .. } = event if let tauri::WindowEvent::CloseRequested { .. } = event
&& let Some(win) = app_handle.get_webview_window(&label_clone) && let Some(win) = app_handle.get_webview_window(&label_clone)
{ {
save_window_state_internal(&win, &db); save_window_state_internal(&win, &db);

View file

@ -99,7 +99,7 @@ pub fn run() {
let handle2 = handle.clone(); let handle2 = handle.clone();
let label2 = label.clone(); let label2 = label.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: _, .. } = event if let tauri::WindowEvent::CloseRequested { .. } = event
&& let Some(win) = handle2.get_webview_window(&label2) && let Some(win) = handle2.get_webview_window(&label2)
{ {
commands::save_window_state_internal(&win, &db2); commands::save_window_state_internal(&win, &db2);