592 lines
27 KiB
Plaintext
592 lines
27 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
import { ScrollView } from "std-widgets.slint";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export struct SftpEntry {
|
|
name: string,
|
|
full-path: string,
|
|
is-dir: bool,
|
|
size: string,
|
|
modified: string,
|
|
}
|
|
|
|
export struct SftpTreeNode {
|
|
path: string,
|
|
name: string,
|
|
depth: int,
|
|
expanded: bool,
|
|
has-children: bool,
|
|
}
|
|
|
|
// One row of the file-list right-click context menu.
|
|
component SftpMenuItem inherits Rectangle {
|
|
in property <string> label;
|
|
in property <brush> tint: Theme.text-primary;
|
|
callback clicked();
|
|
height: 26px;
|
|
border-radius: Theme.radius-sm;
|
|
background: ta.has-hover ? Theme.bg-hover : transparent;
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.clicked(); }
|
|
}
|
|
Text {
|
|
x: 10px;
|
|
text: root.label;
|
|
color: root.tint;
|
|
font-size: Theme.fs-sm;
|
|
vertical-alignment: center;
|
|
height: parent.height;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SftpPanel — left directory tree + right file listing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export component SftpPanel inherits Rectangle {
|
|
// --- Properties --------------------------------------------------------
|
|
in property <string> current-path: "/";
|
|
in property <[SftpEntry]> entries;
|
|
in property <string> status: @tr("SFTP not connected");
|
|
in property <bool> loading: false;
|
|
in property <[SftpTreeNode]> tree-nodes: [];
|
|
in property <bool> app-background-active: false;
|
|
in property <float> ui-opacity: 0.72;
|
|
in-out property <string> path-draft: "/";
|
|
in property <length> file-font-size: 16px;
|
|
in-out property <length> name-column-width: 180px;
|
|
in-out property <bool> name-column-manual: false;
|
|
property <bool> rename-open: false;
|
|
property <string> rename-path;
|
|
property <string> rename-draft;
|
|
property <bool> name-column-dragging: false;
|
|
property <length> name-column-drag-start-x: 0px;
|
|
|
|
// --- Callbacks ---------------------------------------------------------
|
|
callback navigate(string);
|
|
callback download(string);
|
|
callback upload-clicked(string);
|
|
callback refresh(string);
|
|
callback tree-expand(string); // toggle expand/collapse + navigate
|
|
callback delete(string); // remove a remote file/dir
|
|
callback rename(string, string);
|
|
callback edit(string); // download to temp + open + auto-reupload
|
|
callback name-column-width-changed(float);
|
|
callback name-column-auto();
|
|
|
|
// -----------------------------------------------------------------------
|
|
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
|
|
|
|
changed current-path => { root.path-draft = root.current-path; }
|
|
|
|
VerticalLayout {
|
|
spacing: 0;
|
|
|
|
// --- Toolbar (full width) ------------------------------------------
|
|
Rectangle {
|
|
height: 30px;
|
|
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
|
|
|
|
HorizontalLayout {
|
|
height: parent.height;
|
|
padding-left: 6px;
|
|
padding-right: 6px;
|
|
spacing: 4px;
|
|
|
|
// Editable path bar fills the left; action buttons sit on the right.
|
|
Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
horizontal-stretch: 1;
|
|
height: 22px;
|
|
border-radius: Theme.radius-sm;
|
|
border-width: 1px;
|
|
border-color: path-input.has-focus ? Theme.accent : Theme.border-subtle;
|
|
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
|
|
|
|
path-input := TextInput {
|
|
x: 8px;
|
|
width: parent.width - 16px;
|
|
height: parent.height;
|
|
text <=> root.path-draft;
|
|
color: Theme.text-primary;
|
|
font-size: root.file-font-size;
|
|
single-line: true;
|
|
vertical-alignment: center;
|
|
key-pressed(e) => {
|
|
if (e.text == "\n") {
|
|
root.navigate(self.text);
|
|
accept
|
|
} else {
|
|
reject
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
width: 24px; height: 22px;
|
|
border-radius: Theme.radius-sm;
|
|
background: go-ta.has-hover ? Theme.bg-hover : transparent;
|
|
go-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.navigate(root.path-draft); }
|
|
}
|
|
Text { text: "\u{E5C8}"; font-family: "Material Icons";
|
|
color: Theme.text-secondary; font-size: Theme.fs-sm;
|
|
width: parent.width; height: parent.height;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
|
|
up-btn := Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
width: 24px; height: 22px;
|
|
border-radius: Theme.radius-sm;
|
|
background: up-ta.has-hover ? Theme.bg-hover : transparent;
|
|
up-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.navigate(".."); }
|
|
}
|
|
Text { text: "↑"; color: Theme.text-secondary; font-size: Theme.fs-md;
|
|
width: parent.width; height: parent.height;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
|
|
Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
width: 52px; height: 22px;
|
|
border-radius: Theme.radius-sm;
|
|
background: ul-ta.has-hover ? Theme.accent-hover : Theme.accent;
|
|
ul-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.upload-clicked(root.current-path); }
|
|
}
|
|
Text { text: @tr("Upload"); color: #ffffff; font-size: Theme.fs-sm;
|
|
width: parent.width; height: parent.height;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
|
|
Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
width: 24px; height: 22px;
|
|
border-radius: Theme.radius-sm;
|
|
background: ref-ta.has-hover ? Theme.bg-hover : transparent;
|
|
ref-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.refresh(root.current-path); }
|
|
}
|
|
Text { text: "\u{E5D5}"; font-family: "Material Icons";
|
|
color: Theme.text-secondary; font-size: Theme.fs-sm;
|
|
width: parent.width; height: parent.height;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Main area: tree (left) + file list (right) --------------------
|
|
HorizontalLayout {
|
|
vertical-stretch: 1;
|
|
spacing: 0;
|
|
|
|
// ── Left: directory tree ────────────────────────────────────────
|
|
VerticalLayout {
|
|
width: 160px;
|
|
spacing: 0;
|
|
|
|
// Tree header
|
|
Rectangle {
|
|
height: 20px;
|
|
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
|
|
Text {
|
|
x: 8px;
|
|
text: @tr("Directory tree");
|
|
color: Theme.text-muted;
|
|
font-size: max(10px, root.file-font-size - 2px);
|
|
vertical-alignment: center;
|
|
height: parent.height;
|
|
}
|
|
}
|
|
Rectangle { height: 1px; background: Theme.border-subtle; }
|
|
|
|
// Tree list with scrollbar
|
|
ScrollView {
|
|
vertical-stretch: 1;
|
|
|
|
tree-list := VerticalLayout {
|
|
spacing: 0;
|
|
|
|
for node[i] in root.tree-nodes : Rectangle {
|
|
height: max(24px, root.file-font-size + 10px);
|
|
background: node-ta.has-hover
|
|
? Theme.bg-hover
|
|
: (node.path == root.current-path
|
|
? #4a90e230
|
|
: (mod(i, 2) == 0 ? transparent : #ffffff08));
|
|
|
|
node-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.tree-expand(node.path); }
|
|
}
|
|
|
|
HorizontalLayout {
|
|
height: parent.height;
|
|
spacing: 0;
|
|
|
|
// Depth indent spacer
|
|
Rectangle { width: node.depth * 12px; }
|
|
|
|
// Expand/collapse arrow
|
|
Text {
|
|
width: 14px;
|
|
text: node.has-children
|
|
? (node.expanded ? "▼" : "▶")
|
|
: " ";
|
|
color: Theme.text-muted;
|
|
font-size: max(9px, root.file-font-size - 3px);
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
// Folder name
|
|
Text {
|
|
text: node.depth == 0 ? "/" : node.name;
|
|
color: node.path == root.current-path
|
|
? Theme.accent
|
|
: Theme.text-primary;
|
|
font-size: root.file-font-size;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vertical separator
|
|
Rectangle { width: 1px; background: Theme.border-subtle; }
|
|
|
|
// ── Right: file listing ─────────────────────────────────────────
|
|
right-pane := VerticalLayout {
|
|
horizontal-stretch: 1;
|
|
spacing: 0;
|
|
|
|
// Column headers
|
|
Rectangle {
|
|
height: 20px;
|
|
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
|
|
HorizontalLayout {
|
|
height: parent.height;
|
|
padding-left: 8px; padding-right: 8px;
|
|
Text { text: @tr("Name"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
|
|
width: clamp(root.name-column-width, 96px, 360px); vertical-alignment: center; }
|
|
Rectangle {
|
|
width: 6px;
|
|
height: parent.height;
|
|
background: root.name-column-dragging || name-resize-ta.has-hover ? Theme.accent.with-alpha(0.45) : transparent;
|
|
name-resize-ta := TouchArea {
|
|
mouse-cursor: col-resize;
|
|
pointer-event(ev) => {
|
|
if (ev.kind == PointerEventKind.down) {
|
|
root.name-column-dragging = true;
|
|
root.name-column-drag-start-x = self.mouse-x;
|
|
root.name-column-manual = true;
|
|
} else if (ev.kind == PointerEventKind.up) {
|
|
root.name-column-dragging = false;
|
|
root.name-column-width-changed(root.name-column-width / 1px);
|
|
}
|
|
}
|
|
moved => {
|
|
if (root.name-column-dragging) {
|
|
root.name-column-width = clamp(
|
|
root.name-column-width + self.mouse-x - root.name-column-drag-start-x,
|
|
96px,
|
|
360px
|
|
);
|
|
root.name-column-drag-start-x = self.mouse-x;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if root.name-column-manual : Rectangle {
|
|
y: (parent.height - self.height) / 2;
|
|
width: 42px;
|
|
height: 18px;
|
|
border-radius: Theme.radius-sm;
|
|
background: name-auto-ta.has-hover ? Theme.bg-hover : transparent;
|
|
name-auto-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.name-column-manual = false;
|
|
root.name-column-auto();
|
|
root.refresh(root.current-path);
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width;
|
|
height: parent.height;
|
|
text: @tr("Auto");
|
|
color: Theme.accent;
|
|
font-size: max(10px, root.file-font-size - 3px);
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
Text { text: @tr("Size"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
|
|
width: 72px; horizontal-alignment: right; vertical-alignment: center; }
|
|
Text { text: @tr("Modified"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
|
|
width: 116px; horizontal-alignment: right; vertical-alignment: center; }
|
|
}
|
|
}
|
|
Rectangle { height: 1px; background: Theme.border-subtle; }
|
|
|
|
// File list with scrollbar
|
|
ScrollView {
|
|
vertical-stretch: 1;
|
|
|
|
file-list := VerticalLayout {
|
|
spacing: 0;
|
|
|
|
if root.loading : Rectangle {
|
|
height: 40px;
|
|
Text { width: parent.width; height: parent.height;
|
|
text: @tr("Loading..."); color: Theme.text-muted; font-size: root.file-font-size;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
|
|
if !root.loading && root.entries.length == 0 : Rectangle {
|
|
height: 40px;
|
|
Text { width: parent.width; height: parent.height;
|
|
text: @tr("Empty directory"); color: Theme.text-muted; font-size: root.file-font-size;
|
|
horizontal-alignment: center; vertical-alignment: center; }
|
|
}
|
|
|
|
for entry[i] in root.entries : row := Rectangle {
|
|
height: max(26px, root.file-font-size + 12px);
|
|
background: row-ta.has-hover
|
|
? #4a90e225
|
|
: (mod(i, 2) == 0 ? transparent : #ffffff08);
|
|
|
|
// Right-click anchor (relative to this row).
|
|
property <length> mx;
|
|
property <length> my;
|
|
|
|
row-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
double-clicked => {
|
|
if (entry.is-dir) {
|
|
root.navigate(entry.full-path);
|
|
} else {
|
|
root.edit(entry.full-path);
|
|
}
|
|
}
|
|
pointer-event(ev) => {
|
|
if (ev.kind == PointerEventKind.down
|
|
&& ev.button == PointerEventButton.right) {
|
|
row.mx = self.mouse-x;
|
|
row.my = self.mouse-y;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right-click context menu.
|
|
row-menu := PopupWindow {
|
|
x: row.mx;
|
|
y: row.my;
|
|
width: 108px;
|
|
height: (entry.is-dir ? 2 : 4) * 27px + 8px;
|
|
Rectangle {
|
|
background: Theme.bg-panel;
|
|
border-radius: Theme.radius-sm;
|
|
border-width: 1px;
|
|
border-color: Theme.border-strong;
|
|
drop-shadow-blur: 12px;
|
|
drop-shadow-color: #00000060;
|
|
VerticalLayout {
|
|
padding: 4px;
|
|
spacing: 1px;
|
|
if !entry.is-dir : SftpMenuItem {
|
|
label: @tr("Download");
|
|
clicked => { root.download(entry.full-path); }
|
|
}
|
|
SftpMenuItem {
|
|
label: @tr("Rename");
|
|
clicked => {
|
|
root.rename-path = entry.full-path;
|
|
root.rename-draft = entry.name;
|
|
root.rename-open = true;
|
|
}
|
|
}
|
|
if !entry.is-dir : SftpMenuItem {
|
|
label: @tr("Edit");
|
|
clicked => { root.edit(entry.full-path); }
|
|
}
|
|
SftpMenuItem {
|
|
label: @tr("Delete");
|
|
tint: #d86c6c;
|
|
clicked => { root.delete(entry.full-path); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
HorizontalLayout {
|
|
height: parent.height;
|
|
padding-left: 8px; padding-right: 8px; spacing: 4px;
|
|
|
|
Text {
|
|
text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name;
|
|
color: entry.is-dir ? Theme.accent : Theme.text-primary;
|
|
font-size: root.file-font-size;
|
|
width: clamp(root.name-column-width, 96px, 360px);
|
|
overflow: elide;
|
|
vertical-alignment: center;
|
|
}
|
|
Rectangle { width: 6px; height: parent.height; }
|
|
Rectangle { horizontal-stretch: 1; }
|
|
Text {
|
|
text: entry.is-dir ? "" : entry.size;
|
|
color: Theme.text-secondary; font-size: max(10px, root.file-font-size - 2px);
|
|
width: 72px; horizontal-alignment: right; vertical-alignment: center;
|
|
}
|
|
Text {
|
|
text: entry.modified;
|
|
color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
|
|
width: 116px; horizontal-alignment: right; vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if root.rename-open : Rectangle {
|
|
width: parent.width;
|
|
height: parent.height;
|
|
background: #00000040;
|
|
TouchArea { clicked => { root.rename-open = false; } }
|
|
|
|
Rectangle {
|
|
x: (parent.width - self.width) / 2;
|
|
y: 40px;
|
|
width: min(340px, parent.width - 32px);
|
|
height: 118px;
|
|
border-radius: Theme.radius-md;
|
|
border-width: 1px;
|
|
border-color: Theme.border-strong;
|
|
background: Theme.bg-panel;
|
|
drop-shadow-blur: 16px;
|
|
drop-shadow-color: #00000080;
|
|
TouchArea {}
|
|
|
|
VerticalLayout {
|
|
padding: 12px;
|
|
spacing: 8px;
|
|
Text {
|
|
text: @tr("Rename");
|
|
color: Theme.text-primary;
|
|
font-size: Theme.fs-sm;
|
|
font-weight: 600;
|
|
}
|
|
Rectangle {
|
|
height: 30px;
|
|
border-radius: Theme.radius-sm;
|
|
border-width: 1px;
|
|
border-color: rename-input.has-focus ? Theme.accent : Theme.border-subtle;
|
|
background: Theme.bg-root;
|
|
rename-input := TextInput {
|
|
x: 8px;
|
|
width: parent.width - 16px;
|
|
height: parent.height;
|
|
text <=> root.rename-draft;
|
|
color: Theme.text-primary;
|
|
font-size: Theme.fs-sm;
|
|
single-line: true;
|
|
vertical-alignment: center;
|
|
key-pressed(e) => {
|
|
if (e.text == "\n") {
|
|
root.rename(root.rename-path, root.rename-draft);
|
|
root.rename-open = false;
|
|
accept
|
|
} else {
|
|
reject
|
|
}
|
|
}
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
height: 28px;
|
|
spacing: 8px;
|
|
Rectangle { horizontal-stretch: 1; }
|
|
Rectangle {
|
|
width: 68px;
|
|
height: parent.height;
|
|
border-radius: Theme.radius-sm;
|
|
background: rn-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
|
|
rn-cancel-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { root.rename-open = false; }
|
|
}
|
|
Text {
|
|
width: parent.width;
|
|
height: parent.height;
|
|
text: @tr("Cancel");
|
|
color: Theme.text-primary;
|
|
font-size: Theme.fs-sm;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 68px;
|
|
height: parent.height;
|
|
border-radius: Theme.radius-sm;
|
|
background: rn-ok-ta.has-hover ? Theme.accent-hover : Theme.accent;
|
|
rn-ok-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.rename(root.rename-path, root.rename-draft);
|
|
root.rename-open = false;
|
|
}
|
|
}
|
|
Text {
|
|
width: parent.width;
|
|
height: parent.height;
|
|
text: @tr("Save");
|
|
color: #ffffff;
|
|
font-size: Theme.fs-sm;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Status bar (full width) --------------------------------------
|
|
Rectangle {
|
|
height: 18px;
|
|
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
|
|
Text {
|
|
x: 8px;
|
|
text: root.status;
|
|
color: Theme.text-muted;
|
|
font-size: Theme.fs-xs;
|
|
vertical-alignment: center;
|
|
height: parent.height;
|
|
overflow: elide;
|
|
width: parent.width - 16px;
|
|
}
|
|
}
|
|
}
|
|
}
|