Skip to content

Commit 956d988

Browse files
serkancakmakkclaude
andcommitted
feat: çift panel artık bilgisayar ↔ sunucu aktarımını da destekler [minor]
- Sağ panelde "💻 Bu Bilgisayar (yerel)" seçeneği (yalnızca masaüstü uygulamada) - Yerel klasör gez (window.desktop.listDir); 📤 Sola gönder = yerel → aktif sunucuya yükle (/api/upload-local), 📥 Soldan al = sunucu → yerel klasöre indir - Yeni IPC: fs:download (URL'yi yerel klasöre akıtarak kaydeder, çakışmada ad-(n)) electron/preload.js downloadToDir + electron/main.js fs:download - dual-pane.js mode: "remote" | "local"; breadcrumb/up yerel yollara uyarlandı - CHANGELOG + README güncellendi Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad63c4d commit 956d988

6 files changed

Lines changed: 148 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- 🐳 **Docker Compose:** "Docker → Compose" sekmesinde compose projelerini (stack) çalışan/toplam sayısıyla gör; **up / down / restart / stop / pull** ile yönet.
1414

1515
**Bağlantı & güvenlik — yeni**
16-
- 🗂️ **Çoklu panel (split-pane):** Araç çubuğunda **Çift Panel** ile sağ tarafta ikinci bir bağlı sunucuyu yan yana aç; **Sol ➡ Sağ / Sol ⬅ Sağ** düğmeleriyle seçili dosyaları iki sunucu arasında doğrudan aktar (FileZilla tarzı).
16+
- 🗂️ **Çoklu panel (split-pane):** Araç çubuğunda **Çift Panel** ile sağ tarafta ikinci bir bağlı sunucuyu **ya da bu bilgisayarı (yerel, masaüstü uygulamasında)** yan yana aç; **📥 Soldan al / 📤 Sola gönder** ile dosyaları **sunucu↔sunucu** veya **bilgisayar↔sunucu** doğrudan aktar (FileZilla tarzı). Her iki panelin kimliği ve eylem üstte açıkça yazılı.
1717
- 📜 **İşlem günlüğü (audit):** Sidebar → "İşlem Günlüğü" ile hangi sunucuya ne zaman bağlanıldı, hangi dosya silindi/taşındı/kopyalandı/yeniden adlandırıldı/arşivlendi/aktarıldı — zaman damgalı kayıt; temizlenebilir.
1818
- 🛠️ **Bağlantı düzenleme:** Kayıtlı bir sunucunun üzerine gelip ✎ ile **host/port/kullanıcı/ad/grup/protokol** bilgilerini silmeden güncelle (parola/anahtar korunur).
1919
- 🔑 **SSH anahtarı üret & kur:** "Sunucu Araçları → SSH" ile sunucuda ed25519 anahtar çifti üret, açık anahtarı `authorized_keys`'e ekle; özel anahtarı kopyalayıp bağlantına kaydederek parolasız bağlan.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Bağlantılar ve dosya işlemleri zaman damgalı kayıt.
7676
- **Kopyala / Kes / Yapıştır**, yeni klasör, yeniden adlandırma / taşıma, özyinelemeli silme (ilerleme çubuğu)
7777
- **İzinler (chmod)** ve **Özellikler** paneli (sahip/grup, sekizlik+simgesel izin, özyinelemeli boyut)
7878
- **Önizleme:** resim ve PDF'i indirmeden pencerede gör · **yerleşik metin düzenleyici** (`Ctrl/Cmd+S`)
79-
- **Sunucudan sunucuya aktarım** ve **çift panel (split-pane):** iki sunucuyu yan yana açıp aralarında aktar
79+
- **Sunucudan sunucuya aktarım** ve **çift panel (split-pane):** iki sunucuyu — ya da **bu bilgisayarı (yerel ↔ sunucu, masaüstünde)**yan yana açıp aralarında aktar
8080
- Sol kenar çubuğunda **sık kullanılanlar**, hızlı erişim ve **disk kullanım** göstergesi
8181

8282
### 🐳 Docker yönetimi

electron/main.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,33 @@ ipcMain.handle("fs:list", (_e, dir) => {
258258
}
259259
});
260260

261+
// Uzak bir indirme URL'sini yerel klasöre kaydet (çift panel: sunucu → bu bilgisayar).
262+
// URL yerel Express sunucusunu işaret eder (http://127.0.0.1:<port>/api/download…).
263+
ipcMain.handle("fs:download", async (_e, { url, dir, name }) => {
264+
try {
265+
if (!url || !dir || !name) return { ok: false, error: "Eksik parametre." };
266+
const safe = String(name).replace(/[\\/]/g, "_") || "indirme";
267+
let dest = path.join(dir, safe);
268+
// Üzerine yazmamak için gerekiyorsa numara ekle (ad (1).ext …)
269+
if (fs.existsSync(dest)) {
270+
const ext = path.extname(safe), base = path.basename(safe, ext);
271+
let i = 1;
272+
while (fs.existsSync(path.join(dir, `${base} (${i})${ext}`))) i++;
273+
dest = path.join(dir, `${base} (${i})${ext}`);
274+
}
275+
const res = await fetch(url);
276+
if (!res.ok || !res.body) return { ok: false, error: "HTTP " + res.status };
277+
const { Readable } = require("stream");
278+
const ws = fs.createWriteStream(dest);
279+
await new Promise((resolve, reject) => {
280+
Readable.fromWeb(res.body).pipe(ws).on("finish", resolve).on("error", reject);
281+
});
282+
return { ok: true, path: dest };
283+
} catch (e) {
284+
return { ok: false, error: e.message };
285+
}
286+
});
287+
261288
// Güncelleme denetle: paketliyse electron-updater, değilse GitHub API bilgisi
262289
ipcMain.handle("app:check-update", async (_e, opts) => {
263290
const silent = !!(opts && opts.silent);

electron/preload.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ contextBridge.exposeInMainWorld("desktop", {
1818
// Uygulama içi yerel gezgin: ev klasörü ve klasör listeleme.
1919
homeDir: () => ipcRenderer.invoke("fs:home"),
2020
listDir: (p) => ipcRenderer.invoke("fs:list", p),
21+
// Bir URL'yi (uzak dosya indirme ucu) yerel bir klasöre kaydet — çift panel
22+
// "Soldan al" (sunucu → bu bilgisayar) için.
23+
downloadToDir: (url, dir, name) => ipcRenderer.invoke("fs:download", { url, dir, name }),
2124
// Biyometrik (macOS Touch ID) — uygulama kilidi için
2225
biometricAvailable: () => ipcRenderer.invoke("lock:biometric-available"),
2326
biometricPrompt: (reason) => ipcRenderer.invoke("lock:biometric-prompt", reason),

public/js/dual-pane.js

Lines changed: 114 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,120 @@
1-
// Çift panel (split-pane) — sağ tarafta ikinci bir bağlı sunucuyu gezdirir ve
2-
// sol (aktif) panelle arasında sürükle-bırak/düğme ile aktarım yapar.
3-
// Aktarım var olan /api/transfer-remote (sunucudan sunucuya relay) ile yapılır.
1+
// Çift panel (split-pane) — sağ tarafta İKİNCİ BİR SUNUCUYU ya da BU BİLGİSAYARI
2+
// (yerel, yalnızca masaüstü uygulamasında) gezdirir ve sol (aktif) sunucuyla arasında
3+
// dosya aktarır.
4+
// • Sunucu ↔ sunucu: /api/transfer-remote (relay)
5+
// • Yerel → sunucu: /api/upload-local (yükleme)
6+
// • Sunucu → yerel: window.desktop.downloadToDir (indirme, masaüstü)
47
import { $, escapeHtml, toast } from "./dom.js";
58
import { connections, activeConnId, session, cwd } from "./state.js";
69
import { navigate, joinPath, fmtSize, fmtDate, checkedItems } from "./explorer.js";
710
import { runTransfer } from "./transfer-remote.js";
811
import { enqueueTransfer } from "./transfer-queue.js";
912

13+
const LOCAL = "__local__";
1014
let isOpen = false;
11-
const dp = { connId: null, tok: null, cwd: "/", items: [] };
15+
// mode: "remote" | "local"
16+
const dp = { mode: "remote", connId: null, tok: null, cwd: "/", parent: null, items: [] };
1217

18+
const isDesktop = () => !!(window.desktop && window.desktop.isDesktop && window.desktop.listDir);
1319
function listConns() { return connections.filter((c) => c.session); }
1420
function connLabel(c) { return (c.info && (c.info.name || `${c.info.username}@${c.info.host}`)) || c.id; }
1521
function activeConn() { return connections.find((c) => c.id === activeConnId); }
1622
function activeLabel() { const c = activeConn(); return c ? connLabel(c) : "aktif sunucu"; }
23+
function rightLabel() {
24+
if (dp.mode === "local") return "💻 Bu Bilgisayar";
25+
return dp.connId ? connLabel(connections.find((c) => c.id === dp.connId) || {}) : "—";
26+
}
1727

1828
// İki panelin kim olduğunu ve ne yapılacağını açıkça yaz.
1929
function setHint() {
2030
const el = $("dp-xfer-hint");
2131
if (!el) return;
22-
const right = dp.connId ? connLabel(connections.find((c) => c.id === dp.connId) || {}) : "—";
23-
const same = dp.tok && activeConn() && activeConn().session === dp.tok;
32+
const same = dp.mode === "remote" && dp.tok && activeConn() && activeConn().session === dp.tok;
2433
el.innerHTML =
2534
`<span class="dp-side"><b>◀ Sol</b> (senin gezginin): ${escapeHtml(activeLabel())}</span>` +
26-
`<span class="dp-side"><b>Sağ ▶</b>: ${escapeHtml(right)}</span>` +
35+
`<span class="dp-side"><b>Sağ ▶</b>: ${escapeHtml(rightLabel())}</span>` +
2736
(same
2837
? `<span class="dp-warn">⚠ İki panel aynı sunucu — aktarım için farklı bir sunucu seç.</span>`
2938
: `<span class="dp-tip">Aktarmak için dosyaları <b>kutucukla seç</b>, sonra <b>📥 Soldan al</b> / <b>📤 Sola gönder</b>.</span>`);
3039
}
3140

32-
async function listDir(tok, path) {
33-
const res = await fetch("/api/list?path=" + encodeURIComponent(path), { headers: tok ? { "x-session": tok } : {} });
41+
async function listRemote(tok, p) {
42+
const res = await fetch("/api/list?path=" + encodeURIComponent(p), { headers: tok ? { "x-session": tok } : {} });
3443
if (!res.ok) { let e = "Listelenemedi"; try { e = (await res.json()).error || e; } catch (_) {} throw new Error(e); }
3544
return res.json();
3645
}
3746

3847
function populateSelect() {
3948
const sel = $("dp-conn");
4049
const conns = listConns();
41-
sel.innerHTML = conns.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(connLabel(c))}</option>`).join("");
42-
// Var olan seçimi koru; yoksa aktif olmayan ilk bağlantıyı seç.
43-
if (dp.connId && conns.some((c) => c.id === dp.connId)) sel.value = dp.connId;
50+
let html = conns.map((c) => `<option value="${escapeHtml(c.id)}">${escapeHtml(connLabel(c))}</option>`).join("");
51+
if (isDesktop()) html += `<option value="${LOCAL}">💻 Bu Bilgisayar (yerel)</option>`;
52+
sel.innerHTML = html;
53+
// Var olan seçimi koru; yoksa aktif olmayan ilk sunucu, o da yoksa yerel.
54+
if (dp.mode === "local") sel.value = LOCAL;
55+
else if (dp.connId && conns.some((c) => c.id === dp.connId)) sel.value = dp.connId;
4456
else {
4557
const pref = conns.find((c) => c.id !== activeConnId) || conns[0];
4658
if (pref) sel.value = pref.id;
59+
else if (isDesktop()) sel.value = LOCAL;
4760
}
4861
}
4962

5063
async function setConn(id) {
64+
if (id === LOCAL) {
65+
dp.mode = "local"; dp.connId = null; dp.tok = null;
66+
let home = "/"; try { home = await window.desktop.homeDir(); } catch (_) {}
67+
dp.cwd = home;
68+
setHint();
69+
return loadLocal(home);
70+
}
5171
const c = connections.find((x) => x.id === id);
5272
if (!c) return;
53-
dp.connId = id; dp.tok = c.session; dp.cwd = c.homePath || "/";
73+
dp.mode = "remote"; dp.connId = id; dp.tok = c.session; dp.cwd = c.homePath || "/"; dp.parent = null;
5474
setHint();
5575
await loadDp();
5676
}
5777

5878
async function loadDp() {
79+
if (dp.mode === "local") return loadLocal(dp.cwd);
5980
const list = $("dp-list");
6081
list.innerHTML = `<div class="dk-msg">Yükleniyor…</div>`;
6182
if (!dp.tok) { list.innerHTML = `<div class="dk-msg">Sağ panel için bir sunucu seç.</div>`; renderBread(); return; }
6283
try {
63-
const data = await listDir(dp.tok, dp.cwd);
84+
const data = await listRemote(dp.tok, dp.cwd);
6485
dp.cwd = data.path;
65-
dp.items = data.items || [];
86+
dp.items = (data.items || []).map((i) => ({ name: i.name, type: i.type, size: i.size, mtime: i.mtime }));
6687
render();
6788
} catch (e) { list.innerHTML = `<div class="dk-msg">Hata: ${escapeHtml(e.message)}</div>`; }
6889
}
6990

91+
async function loadLocal(target) {
92+
const list = $("dp-list");
93+
list.innerHTML = `<div class="dk-msg">Yükleniyor…</div>`;
94+
let r;
95+
try { r = await window.desktop.listDir(target); } catch (e) { r = { ok: false, error: e.message }; }
96+
if (!r || !r.ok) { list.innerHTML = `<div class="dk-msg">Klasör açılamadı: ${escapeHtml((r && r.error) || "?")}</div>`; return; }
97+
dp.cwd = r.path; dp.parent = r.parent;
98+
const sep = r.path.includes("\\") ? "\\" : "/";
99+
dp.items = (r.entries || []).map((e) => ({
100+
name: e.name, type: e.isDir ? "dir" : "file", size: e.size || 0, mtime: e.mtime || 0,
101+
abs: r.path.replace(/[\\/]+$/, "") + sep + e.name,
102+
}));
103+
render();
104+
}
105+
70106
function renderBread() {
71107
const bc = $("dp-bread");
72108
if (!bc) return;
73109
bc.innerHTML = "";
110+
if (dp.mode === "local") {
111+
// Yerel: yol metni + (varsa) üst klasör kısayolu — segment yeniden kurmak yerine güvenli.
112+
const span = document.createElement("span");
113+
span.className = "crumb dp-localpath";
114+
span.textContent = "💻 " + dp.cwd;
115+
bc.appendChild(span);
116+
return;
117+
}
74118
const mk = (label, path) => {
75119
const s = document.createElement("span");
76120
s.className = "crumb";
@@ -110,37 +154,75 @@ function render() {
110154
cb._item = item;
111155
cb.addEventListener("click", (e) => e.stopPropagation());
112156
if (item.type === "dir" || item.type === "link") {
113-
row.addEventListener("dblclick", () => { dp.cwd = joinPath(dp.cwd, item.name); loadDp(); });
157+
row.addEventListener("dblclick", () => {
158+
if (dp.mode === "local") loadLocal(item.abs);
159+
else { dp.cwd = joinPath(dp.cwd, item.name); loadDp(); }
160+
});
114161
}
115162
list.appendChild(row);
116163
});
117164
}
118165

119-
function dpSelectedPaths() {
120-
return Array.from($("dp-list").querySelectorAll(".dp-check:checked")).map((cb) => joinPath(dp.cwd, cb._item.name));
166+
// Seçili sağ-panel öğeleri (item nesneleri)
167+
function dpSelectedItems() {
168+
return Array.from($("dp-list").querySelectorAll(".dp-check:checked")).map((cb) => cb._item);
121169
}
122170

123-
// Sol (aktif) → Sağ (dp)
171+
// ---- 📥 Soldan al: sol (aktif sunucu) seçilenleri → sağ panele ----
124172
function transferLeftToRight() {
173+
const items = checkedItems();
174+
if (!items.length) return toast("Sol panelde aktarılacak öğe seç (kutucukla).", true);
175+
176+
if (dp.mode === "local") {
177+
// Sunucu → yerel klasöre indir (masaüstü)
178+
const destDir = dp.cwd;
179+
enqueueTransfer(`İndir → 💻 ${destDir} (${items.length})`, async () => {
180+
let ok = 0, fail = 0;
181+
for (const it of items) {
182+
const full = joinPath(cwd, it.name);
183+
const isDir = it.type === "dir";
184+
const url = `${location.origin}/api/${isDir ? "download-folder" : "download"}?session=${encodeURIComponent(session)}&path=${encodeURIComponent(full)}`;
185+
const name = isDir ? it.name + ".tar.gz" : it.name;
186+
try {
187+
const r = await window.desktop.downloadToDir(url, destDir, name);
188+
if (r && r.ok) ok++; else fail++;
189+
} catch (_) { fail++; }
190+
}
191+
toast(fail ? `${ok} indirildi, ${fail} başarısız` : `${ok} öğe indirildi → ${destDir}`, !!fail);
192+
loadLocal(destDir);
193+
});
194+
return;
195+
}
196+
197+
// Sunucu → sunucu
125198
if (!dp.tok) return toast("Önce sağ panel için sunucu seç.", true);
126-
const sel = checkedItems().map((it) => joinPath(cwd, it.name));
127-
if (!sel.length) return toast("Sol panelde aktarılacak öğe seç (kutucukla).", true);
128-
const c = connections.find((x) => x.id === activeConnId);
199+
const c = activeConn();
129200
if (c && c.session === dp.tok) return toast("İki panel aynı sunucu — farklı bir sunucu seç.", true);
130-
const rightName = connLabel(connections.find((x) => x.id === dp.connId) || {});
131-
enqueueTransfer(`Soldan al → ${rightName} (${sel.length})`, async () => {
201+
const sel = items.map((it) => joinPath(cwd, it.name));
202+
enqueueTransfer(`Soldan al → ${rightLabel()} (${sel.length})`, async () => {
132203
await runTransfer(session, dp.tok, dp.cwd, sel);
133204
loadDp();
134205
});
135206
}
136207

137-
// Sağ (dp) → Sol (aktif)
208+
// ---- 📤 Sola gönder: sağ panel seçilenleri → sol (aktif sunucu) ----
138209
function transferRightToLeft() {
210+
const items = dpSelectedItems();
211+
if (!items.length) return toast("Sağ panelde aktarılacak öğe seç (kutucukla).", true);
212+
213+
if (dp.mode === "local") {
214+
// Yerel → aktif sunucunun açık klasörüne yükle
215+
const abs = items.map((it) => it.abs).filter(Boolean);
216+
enqueueTransfer(`Yükle → ${activeLabel()} (${abs.length})`, () =>
217+
import("./local-explorer.js").then((m) => m.uploadLocalPaths(abs, abs)));
218+
return;
219+
}
220+
221+
// Sunucu → sunucu
139222
if (!dp.tok) return toast("Önce sağ panel için sunucu seç.", true);
140-
const sel = dpSelectedPaths();
141-
if (!sel.length) return toast("Sağ panelde aktarılacak öğe seç (kutucukla).", true);
142-
const c = connections.find((x) => x.id === activeConnId);
223+
const c = activeConn();
143224
if (c && c.session === dp.tok) return toast("İki panel aynı sunucu — farklı bir sunucu seç.", true);
225+
const sel = items.map((it) => joinPath(dp.cwd, it.name));
144226
enqueueTransfer(`Sola gönder → ${activeLabel()} (${sel.length})`, async () => {
145227
await runTransfer(dp.tok, session, cwd, sel);
146228
navigate(cwd, false);
@@ -155,11 +237,10 @@ function toggle() {
155237
pane.hidden = !isOpen;
156238
$("btn-split").classList.toggle("active", isOpen);
157239
if (!isOpen) return;
158-
// Sol tarafta panel (dashboard) açıksa dosya gezginine geç — yoksa kullanıcı
159-
// kendi dosyalarını göremez ve aktarım için seçim yapamaz.
240+
// Sol tarafta panel (dashboard) açıksa dosya gezginine geç.
160241
import("./dashboard.js").then((m) => m.showFilesView()).catch(() => {});
161-
if (listConns().length < 2)
162-
toast("Çift panel için ikinci bir sunucuya bağlan (yeni sekmede). Tek sunucu arasında aktarım yapılamaz.", true);
242+
if (listConns().length < 2 && !isDesktop())
243+
toast("Çift panel için ikinci bir sunucuya bağlan (yeni sekmede).", true);
163244
setHint();
164245
populateSelect();
165246
const id = $("dp-conn").value;
@@ -173,6 +254,7 @@ export function initDualPane() {
173254
$("dp-close").addEventListener("click", () => { if (isOpen) toggle(); });
174255
$("dp-refresh").addEventListener("click", loadDp);
175256
$("dp-up").addEventListener("click", () => {
257+
if (dp.mode === "local") { if (dp.parent) loadLocal(dp.parent); return; }
176258
if (dp.cwd === "/") return;
177259
dp.cwd = dp.cwd.replace(/\/[^/]+\/?$/, "") || "/";
178260
loadDp();

public/style.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3399,6 +3399,8 @@ body.locked { overflow: hidden; }
33993399
.dp-bread .crumb { cursor: pointer; color: var(--accent-2); padding: 1px 4px; border-radius: 5px; }
34003400
.dp-bread .crumb:hover { background: var(--hover); }
34013401
.dp-bread .sep { color: var(--muted); }
3402+
.dp-bread .dp-localpath { cursor: default; color: var(--text); font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3403+
.dp-bread .dp-localpath:hover { background: none; }
34023404
.dp-transfer { display: flex; gap: 8px; padding: 8px 10px 6px; }
34033405
.dp-transfer .tbtn { flex: 1; }
34043406
.dp-xfer-hint {

0 commit comments

Comments
 (0)