20260619
This commit is contained in:
591
ui/sftp_panel.slint
Normal file
591
ui/sftp_panel.slint
Normal file
@@ -0,0 +1,591 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user