Compare commits

...

65 Commits

Author SHA1 Message Date
Christian Visintin 0ad18ea5e7 chore: update release date
Close inactive issues / close-issues (push) Has been cancelled
codeberg-mirror / mirror (push) Has been cancelled
Install.sh / build (macos-latest) (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
Linux / build-linux (push) Has been cancelled
MacOS / build-macos (push) Has been cancelled
Deploy static content to Pages / deploy (push) Has been cancelled
Windows / build-windows (push) Has been cancelled
2026-04-18 22:25:40 +02:00
Christian Visintin 2ed71c7ff8 chore: 1.0.0 changelog 2026-04-19 01:54:46 +05:30
Christian Visintin 49102985a4 ci: add linux and windows aarch64 build targets 2026-04-19 01:54:46 +05:30
Christian Visintin bc59df494b fix: filter self-references and dot entries from remote directory listings
Some non-compliant FTP servers (e.g. LiteSpeed) include a self-reference
to the listed directory in the LIST response, causing the current folder
to appear as a duplicate entry in the explorer.

Closes #410
2026-04-19 01:54:46 +05:30
Christian Visintin 080c013fab build: upgrade remotefs-ssh to 0.8.3
closes #414
2026-04-19 01:54:46 +05:30
Christian Visintin 6252df2959 build: migrate to tui-realm 4.0
Upgrade tuirealm (3.x -> 4.0.0), tui-realm-stdlib (3 -> 4), tui-term
(0.2 -> 0.3). Apply all breaking changes from the 4.0 migration guide
across the termscp UI.

Key changes:

- Root-level re-exports removed; imports moved to module-qualified
  paths (`tuirealm::application`, `::component`, `::event`, `::props`,
  `::state`, `::subscription`, `::listener`, `::ratatui`). Same for
  stdlib component types (`tui_realm_stdlib::components::*`).
- `MockComponent` trait renamed to `Component`; old `Component` trait
  renamed to `AppComponent`. `#[derive(MockComponent)]` is now
  `#[derive(Component)]`. `Component::on` now takes `&Event<_>`.
- `TextSpan` replaced with `SpanStatic`/`LineStatic`/`TextStatic`
  (ratatui-based); tuple `(String, Alignment)` titles replaced with
  the new `Title` builder; `Alignment` split into
  `HorizontalAlignment`/`VerticalAlignment`; stdlib components use
  `.alignment_horizontal` instead of `.alignment`.
- `State::One`/`PropPayload::One` -> `Single`. `CmdResult::None`
  -> `NoChange`. `Props::get_or` removed; `Props::get` now returns a
  borrowed `Option<&AttrValue>` (call sites switched to
  `.and_then(AttrValue::as_*)`). `Component::query` returns
  `Option<QueryResult<'a>>`.
- `Attribute::HighlightedColor` -> `HighlightStyle` (a full `Style`).
  `.highlighted_*` helpers renamed to `.highlight_*`.
- `PollStrategy::UpTo(n)` now requires a `Duration`; tick timeout moved
  from `EventListenerCfg::poll_timeout` into `PollStrategy`.
- `TerminalBridge` removed; `Context` now holds
  `CrosstermTerminalAdapter` directly and enables raw mode + alternate
  screen explicitly. The `TerminalAdapter` trait is imported where its
  methods are used.
- `Update` trait removed; activity `update` methods are plain inherent
  functions.
- `ProgressBar` replaced by stdlib `Gauge`. Paragraph `.wrap` renamed
  to `.wrap_trim`; `.text` now takes an `Into<Text>`. Stdlib `List` row
  items are now individual lines (`Vec<Span>` per row) rather than a
  `Table` of spans; custom `FileList`/`Log` convert between the two
  models.
- Radio builders drop `.foreground(color)` so unselected items render
  with the terminal default foreground, and set
  `highlight_style(Style::default().fg(color).add_modifier(REVERSED))`
  so the selected entry is visibly highlighted only with the theme
  color.
- Custom `FileList` keeps the selected row highlighted with the full
  highlight style when focused and falls back to a foreground-only
  style when unfocused.
- Theme loading is now backwards compatible: `Theme` uses a custom
  `Deserialize` through an intermediate `ThemeFile` with optional
  fields, so missing keys, unknown values or legacy aliases
  (`transfer_progress_bar_full`/`_partial`) fall back to defaults on a
  per-field basis instead of failing the whole load.
2026-04-19 01:54:46 +05:30
Christian Visintin 9160c52cb0 build: remotefs-ssh 0.8.2
this version removes any usage of sh commands from the sftp backend and only uses pure protocol functions

closes #409
2026-04-19 01:54:46 +05:30
Christian Visintin 38f1fccfd0 build: remotefs-ssh 0.8.1 2026-04-19 01:54:46 +05:30
Christian Visintin 6ceaf048b5 fix: render progress bar immediately after mounting
Call self.view() right after mount_progress_bar() at all 6 call sites
so the bar is visible on screen before the transfer loop begins.
2026-04-19 01:54:46 +05:30
Christian Visintin 534c16427e fix: use time-based redraw interval instead of progress-delta threshold
The old 1% progress threshold caused the UI to appear frozen on large
files (e.g. 1GB) because many read/write iterations passed between
redraws. Switching to a 100ms time-based interval ensures consistent
UI responsiveness regardless of file size.
2026-04-19 01:54:46 +05:30
Christian Visintin 9b19240925 feat: consolidate theme progress bar fields into single transfer_progress_bar 2026-04-19 01:54:46 +05:30
Christian Visintin 0ddbf3b104 feat: update transfer loop to use unified TransferProgress 2026-04-19 01:54:46 +05:30
Christian Visintin 126e58503f feat: update progress bar display for new unified data model 2026-04-19 01:54:46 +05:30
Christian Visintin 0be8b60c7d feat: simplify progress bar layout to single component 2026-04-19 01:54:46 +05:30
Christian Visintin 4ed6b118ca feat: replace dual progress bar components with single TransferProgressBar 2026-04-19 01:54:46 +05:30
Christian Visintin a48b226d9b feat: rework TransferProgress to track bytes with lazy estimation 2026-04-19 01:54:46 +05:30
Christian Visintin e11af03fdd chore: enable safe clippy pedantic lints 2026-04-19 01:54:46 +05:30
Christian Visintin f044e6ace2 docs: document ssh key storage API 2026-04-19 01:54:46 +05:30
Christian Visintin e056afbd28 test: extend system regression coverage 2026-04-19 01:54:46 +05:30
Christian Visintin 9442e3b150 fix: normalize localhost relative path checks 2026-04-19 01:54:46 +05:30
Christian Visintin 9c00b9a86c test: extend config and explorer regression coverage 2026-04-19 01:54:46 +05:30
Christian Visintin 622cfe260a test: add parser and bookmark regression coverage 2026-04-19 01:54:46 +05:30
Christian Visintin 0c3ced012e refactor: split auth view helpers by responsibility 2026-04-19 01:54:46 +05:30
Christian Visintin d60c5007f9 docs: complete remaining core module docs 2026-04-19 01:54:46 +05:30
Christian Visintin 7e10b8e68b docs: document parser and file transfer params 2026-04-19 01:54:46 +05:30
Christian Visintin 08509caf1b docs: document core host and ssh modules 2026-04-19 01:54:46 +05:30
Christian Visintin 89de2d2dfa docs: add core module and API documentation 2026-04-19 01:54:46 +05:30
Christian Visintin abc80b93ad refactor: split auth update handlers by context 2026-04-19 01:54:46 +05:30
Christian Visintin 1b79a6fc91 refactor: split auth form components by protocol 2026-04-19 01:54:46 +05:30
Christian Visintin 5a3aa64fba refactor: split parser internals into focused modules 2026-04-19 01:54:46 +05:30
Christian Visintin bc4b096077 fix: stabilize core error handling
Remove production panic and unwrap paths from core modules.

Propagate bookmark encryption failures, harden file watcher and temp mapped file handling, and clean up dead code in shared utilities.
2026-04-19 01:54:46 +05:30
Christian Visintin 0025b87b0f fix: sync browsing when entering a directory from filtered/fuzzy view
Closes #382
2026-04-19 01:54:46 +05:30
Christian Visintin bd0843d3da build: replace version-compare crate with semver
closes #399
2026-04-19 01:54:46 +05:30
Christian Visintin 0ba43d5714 ci: check fmt with nightly toolchain 2026-04-19 01:54:46 +05:30
Christian Visintin 97e1ca789a chore: add fmt+clippy convention to CLAUDE.md and apply nightly fmt 2026-04-19 01:54:46 +05:30
Christian Visintin 2df13fac10 fix: pass full command string to exec, not just the first word
The `Exec` arm of `Command::from_str` only captured the first
whitespace-delimited token, silently dropping all arguments.
Now passes the entire input string so e.g. `ls -la /tmp` works.
2026-04-19 01:54:46 +05:30
Christian Visintin 186313f57c fix: resolve . and .. in terminal cd and prevent panic in path elide
`absolutize` now lexically normalizes paths so `cd ..` navigates to the
parent directory instead of appending `..` literally. Also guard against
`file_name()` returning `None` in `fmt_path_elide_ex`, which caused a
panic on paths containing unresolved `..` components.

Closes #402
2026-04-19 01:54:46 +05:30
Christian Visintin 8b0be49609 ci: run test workflows once 2026-04-19 01:54:46 +05:30
Christian Visintin e062ee0447 chore: format toml 2026-04-19 01:54:46 +05:30
Christian Visintin c81cf39fe9 build: replaced libssh with russh for remotefs-ssh 2026-04-19 01:54:46 +05:30
Christian Visintin 9cfef867a9 fix: return after empty terminal prompt 2026-04-19 01:54:46 +05:30
Christian Visintin 18314e6a2e chore: 1.0.0 version into manifests 2026-04-19 01:54:46 +05:30
Christian Visintin 9f3df1a79a style: linter 2026-04-19 01:54:46 +05:30
Christian Visintin fe424107e9 build: removed hostname, use whoami instead
whoami provides `hostname` function, so we don't need the hostname dependency, since whoami is also being used for getting the username

closes #398
2026-04-19 01:54:46 +05:30
Christian Visintin d97535894c fix: replace recursive byte-counting with entry-based transfer progress (#395)
* fix: replace recursive byte-counting with entry-based transfer progress

Replace the expensive recursive `get_total_transfer_size` pre-calculation
with a lightweight entry-based counter (`TransferProgress`) for the
overall progress bar. This avoids deep `list_dir` traversals before
transfers begin, which could cause FTP idle-timeout disconnections on
large directory trees.

The per-file byte-level progress bar (`ProgressStates`) remains
unchanged. Bytes are still tracked via `TransferStates::add_bytes` for
notification threshold logic.

Closes #384
2026-04-19 01:54:46 +05:30
Christian Visintin 4d65e48b3c fix: replace magic-crypt with aes-gcm for bookmark encryption
magic-crypt has known vulnerabilities. Replace it with aes-gcm for new
encryption (authenticated, with random nonces) while keeping a legacy
AES-128-CBC decryption path to transparently handle existing bookmarks.
2026-04-19 01:54:46 +05:30
Christian Visintin 0d24662b3d refactor: migrate from mod.rs to named module files 2026-04-19 01:54:46 +05:30
Christian Visintin bc14b049c0 chore: add centralized lint configuration to Cargo.toml 2026-04-19 01:54:46 +05:30
Christian Visintin d32c588f15 refactor: replace lazy_static with std::sync::LazyLock 2026-04-19 01:54:46 +05:30
Christian Visintin 60e86aff2a perf: use sort_by_cached_key to avoid repeated lowercase allocations in file sorting 2026-04-19 01:54:46 +05:30
Christian Visintin 5f83a1468c fix: correct typos in BadSytax and theme_provider log messages 2026-04-19 01:54:46 +05:30
Christian Visintin 5758a12c3b fix: replace assert! calls in UI activities with graceful error handling 2026-04-19 01:54:46 +05:30
Christian Visintin 49a74d02ea fix: replace panics reachable from user input with proper error handling 2026-04-19 01:54:46 +05:30
Christian Visintin f6d35f1eea refactor: FileTransferActivity pane-agnostic dispatch (#386)
Comprehensive design for incremental refactoring of the 13k-line
FileTransferActivity god-struct using a unified Pane abstraction.
Detailed step-by-step plan covering 6 phases: split monoliths,
error handling, Pane struct, action dedup, session split, view reorg.
Extract 26 popup components from the monolithic 1,868-line popups.rs
into 20 individual files under popups/. Each file contains one or two
related components with their own imports. The popups.rs module file
now contains only module declarations and re-exports.
Replace 8 panic!() calls with error!() logging and early returns/fallthrough.
These panics documented invariants (e.g. "this tab can't do X") but would crash
the app if somehow triggered. Error logging is safer and more resilient.
Replace raw FileExplorer fields in Browser with Pane structs that bundle
the explorer and connected state. Move host_bridge_connected and
remote_connected from FileTransferActivity into the panes. Add navigation
API (fs_pane, opposite_pane, is_find_tab) for future unification tasks.
Rename private get_selected_file to get_selected_file_by_id and add three
new unified methods (get_selected_entries, get_selected_file, is_selected_one)
that dispatch based on self.browser.tab(). Old per-tab methods are kept for
now until their callers are migrated in subsequent tasks.
Collapse _local_/_remote_ action method pairs (mkdir, delete, symlink,
chmod, rename, copy) into unified methods that branch internally on
is_local_tab(). This halves the number of action methods and simplifies
the update.rs dispatch logic. Also unifies ShowFileInfoPopup and
ShowChmodPopup dispatching to use get_selected_entries().
Move `host_bridge` and `client` filesystem fields from FileTransferActivity
into the Pane struct, enabling tab-agnostic dispatch via `fs_pane()`/
`fs_pane_mut()`. This eliminates most `is_local_tab()` branching across
15+ action files.
Key changes:
- Add `fs: Box<dyn HostBridge>` to Pane, remove from FileTransferActivity
- Replace per-side method pairs with unified pane-dispatched methods
- Unify navigation (changedir, reload, scan, file_exists, has_file_changed)
- Replace 147-line popup if/else chain with data-driven priority table
- Replace assert!/panic!/unreachable! with proper error handling
- Fix typo "filetransfer_activiy" across ~29 files
- Add unit tests for Pane

Net result: -473 lines, single code path for most file operations.
2026-04-19 01:54:46 +05:30
Christian Visintin 3fb61a76fe chore: funding
MacOS / build (push) Has been cancelled
Deploy static content to Pages / deploy (push) Has been cancelled
Windows / build (push) Has been cancelled
codeberg-mirror / mirror (push) Has been cancelled
Install.sh / build (macos-latest) (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
Linux / build (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
2026-02-24 12:33:58 +01:00
Christian Visintin ff4452c971 chore: WTF CTRL SAVE DOESNT FUCKING WORK 2026-02-09 10:27:21 +01:00
Christian Visintin fd1d8a0e26 chore: Install with apt if installed 2026-02-09 10:25:57 +01:00
Christian Visintin b0d6da8e23 chore: Copilot is so dumb 2026-02-09 10:12:54 +01:00
Christian Visintin 4ca0865fe3 chore: Use apt to install termscp on debian base to prevent broken deps 2026-02-09 10:12:04 +01:00
Christian Visintin b8115362f8 ci: Codeberg mirroring 2026-01-30 14:57:03 +01:00
Christian Visintin 155f747563 docs: date 2025-12-20 17:12:50 +01:00
Christian Visintin b04976bde3 fix: Updated dependencies to allow build on NetBSD
closes #371
2025-12-20 16:33:50 +01:00
veeso 5f7a0d8a46 fix: install.sh deb name
Install.sh / build (push) Has been cancelled
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
2025-12-02 14:27:34 +01:00
veeso 694232564a fix: install.sh deb name 2025-12-02 14:25:55 +01:00
veeso 54b674ad43 ci: windows artifact name
Deploy static content to Pages / deploy (push) Has been cancelled
Windows / build (push) Has been cancelled
Install.sh / build (push) Has been cancelled
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
2025-11-11 12:34:36 +01:00
201 changed files with 16446 additions and 14099 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: veeso
liberapay: veeso
+14 -3
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
env:
TERMSCP_VERSION: "0.19.0"
TERMSCP_VERSION: "1.0.0"
jobs:
build-binaries:
@@ -28,14 +28,25 @@ jobs:
target: x86_64-unknown-linux-gnu
debian_suffix: amd64
- release_for: Linux-aarch64
os: ubuntu-24.04-arm
platform: linux
target: aarch64-unknown-linux-gnu
debian_suffix: arm64
- release_for: Windows-x86_64
os: windows-latest
platform: windows
target: x86_64-pc-windows-msvc
- release_for: Windows-aarch64
os: windows-11-arm
platform: windows
target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
@@ -148,7 +159,7 @@ jobs:
with:
if-no-files-found: error
retention-days: 1
name: termscp-${{ matrix.platform.target }}
name: termscp-v${{ env.TERMSCP_VERSION }}-${{ matrix.platform.target }}
path: target/${{ matrix.platform.target }}/release/termscp.exe
- name: Upload artifact (Deb)
+17
View File
@@ -0,0 +1,17 @@
name: codeberg-mirror
on:
push:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: "Mirror to Codeberg"
uses: yesolutions/mirror-action@v0.7.0
with:
REMOTE: 'ssh://git@codeberg.org/veeso/termscp.git'
GIT_SSH_PRIVATE_KEY: ${{ secrets.GIT_SSH_PRIVATE_KEY }}
GIT_SSH_NO_VERIFY_HOST: "true"
+9 -5
View File
@@ -11,13 +11,17 @@ env:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os:
- ubuntu-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: sudo apt update && sudo apt install -y curl wget libsmbclient
- uses: actions/checkout@v6
- name: Install termscp from script
run: |
./install.sh -v=0.12.3 -f
./install.sh -f
which termscp || exit 1
termscp --version
+9 -4
View File
@@ -6,6 +6,7 @@ on:
- "*.md"
- "./site/**/*"
push:
branches: [ main ]
paths-ignore:
- "*.md"
- "./site/**/*"
@@ -14,13 +15,19 @@ env:
CARGO_TERM_COLOR: always
jobs:
build:
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- name: Install dependencies
run: sudo apt update && sudo apt install -y libdbus-1-dev libsmbclient-dev
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: nightly
components: rustfmt, clippy
- name: Format
run: cargo +nightly fmt --all -- --check
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
@@ -30,7 +37,5 @@ jobs:
with:
command: test
args: --no-default-features --features github-actions --no-fail-fast
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy -- -Dwarnings
+3 -2
View File
@@ -6,6 +6,7 @@ on:
- "*.md"
- "./site/**/*"
push:
branches: [ main ]
paths-ignore:
- "*.md"
- "./site/**/*"
@@ -14,10 +15,10 @@ env:
CARGO_TERM_COLOR: always
jobs:
build:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
+3 -2
View File
@@ -6,6 +6,7 @@ on:
- "*.md"
- "./site/**/*"
push:
branches: [ main ]
paths-ignore:
- "*.md"
- "./site/**/*"
@@ -14,11 +15,11 @@ env:
CARGO_TERM_COLOR: always
jobs:
build:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
+4
View File
@@ -24,3 +24,7 @@ dist/pkgs/arch/*.tar.gz
dist/pkgs/
dist/build/macos/openssl/
.idea/
.claude/
+218 -40
View File
@@ -1,47 +1,225 @@
# Changelog
- [Changelog](#changelog)
- [0.19.0](#0190)
- [0.18.0](#0180)
- [0.17.0](#0170)
- [0.16.1](#0161)
- [0.16.0](#0160)
- [0.15.0](#0150)
- [0.14.0](#0140)
- [0.13.0](#0130)
- [0.12.3](#0123)
- [0.12.2](#0122)
- [0.12.1](#0121)
- [0.12.0](#0120)
- [0.11.3](#0113)
- [0.11.2](#0112)
- [0.11.1](#0111)
- [0.11.0](#0110)
- [0.10.0](#0100)
- [0.9.0](#090)
- [0.8.2](#082)
- [0.8.1](#081)
- [0.8.0](#080)
- [0.7.0](#070)
- [0.6.1](#061)
- [0.6.0](#060)
- [0.5.1](#051)
- [0.5.0](#050)
- [0.4.2](#042)
- [0.4.1](#041)
- [0.4.0](#040)
- [0.3.3](#033)
- [0.3.2](#032)
- [0.3.1](#031)
- [0.3.0](#030)
- [0.2.0](#020)
- [0.1.3](#013)
- [0.1.2](#012)
- [0.1.1](#011)
- [0.1.0](#010)
---
## 1.0.0
Released on 2026-04-18
### Added
- rework TransferProgress to track bytes with lazy estimation
- replace dual progress bar components with single TransferProgressBar
- simplify progress bar layout to single component
- update progress bar display for new unified data model
- update transfer loop to use unified TransferProgress
- consolidate theme progress bar fields into single transfer_progress_bar
### CI
- Codeberg mirroring
- run test workflows once
- check fmt with nightly toolchain
- add linux and windows aarch64 build targets
### Changed
- FileTransferActivity pane-agnostic dispatch (#386)
> Comprehensive design for incremental refactoring of the 13k-line
> FileTransferActivity god-struct using a unified Pane abstraction.
> Detailed step-by-step plan covering 6 phases: split monoliths,
> error handling, Pane struct, action dedup, session split, view reorg.
> Extract 26 popup components from the monolithic 1,868-line popups.rs
> into 20 individual files under popups/. Each file contains one or two
> related components with their own imports. The popups.rs module file
> now contains only module declarations and re-exports.
> Replace 8 panic!() calls with error!() logging and early returns/fallthrough.
> These panics documented invariants (e.g. "this tab can't do X") but would crash
> the app if somehow triggered. Error logging is safer and more resilient.
> Replace raw FileExplorer fields in Browser with Pane structs that bundle
> the explorer and connected state. Move host_bridge_connected and
> remote_connected from FileTransferActivity into the panes. Add navigation
> API (fs_pane, opposite_pane, is_find_tab) for future unification tasks.
> Rename private get_selected_file to get_selected_file_by_id and add three
> new unified methods (get_selected_entries, get_selected_file, is_selected_one)
> that dispatch based on self.browser.tab(). Old per-tab methods are kept for
> now until their callers are migrated in subsequent tasks.
> Collapse _local_/_remote_ action method pairs (mkdir, delete, symlink,
> chmod, rename, copy) into unified methods that branch internally on
> is_local_tab(). This halves the number of action methods and simplifies
> the update.rs dispatch logic. Also unifies ShowFileInfoPopup and
> ShowChmodPopup dispatching to use get_selected_entries().
> Move `host_bridge` and `client` filesystem fields from FileTransferActivity
> into the Pane struct, enabling tab-agnostic dispatch via `fs_pane()`/
> `fs_pane_mut()`. This eliminates most `is_local_tab()` branching across
> 15+ action files.
> Key changes:
> - Add `fs: Box<dyn HostBridge>` to Pane, remove from FileTransferActivity
> - Replace per-side method pairs with unified pane-dispatched methods
> - Unify navigation (changedir, reload, scan, file_exists, has_file_changed)
> - Replace 147-line popup if/else chain with data-driven priority table
> - Replace assert!/panic!/unreachable! with proper error handling
> - Fix typo "filetransfer_activiy" across ~29 files
> - Add unit tests for Pane
>
> Net result: -473 lines, single code path for most file operations.
- replace lazy_static with std::sync::LazyLock
- migrate from mod.rs to named module files
- split parser internals into focused modules
- split auth form components by protocol
- split auth update handlers by context
- split auth view helpers by responsibility
### Documentation
- date
- add core module and API documentation
- document core host and ssh modules
- document parser and file transfer params
- complete remaining core module docs
- document ssh key storage API
### Fixed
- replace panics reachable from user input with proper error handling
- replace assert! calls in UI activities with graceful error handling
- correct typos in BadSytax and theme_provider log messages
- replace magic-crypt with aes-gcm for bookmark encryption
> magic-crypt has known vulnerabilities. Replace it with aes-gcm for new
> encryption (authenticated, with random nonces) while keeping a legacy
> AES-128-CBC decryption path to transparently handle existing bookmarks.
- replace recursive byte-counting with entry-based transfer progress (#395)
> * fix: replace recursive byte-counting with entry-based transfer progress
>
> Replace the expensive recursive `get_total_transfer_size` pre-calculation
> with a lightweight entry-based counter (`TransferProgress`) for the
> overall progress bar. This avoids deep `list_dir` traversals before
> transfers begin, which could cause FTP idle-timeout disconnections on
> large directory trees.
>
> The per-file byte-level progress bar (`ProgressStates`) remains
> unchanged. Bytes are still tracked via `TransferStates::add_bytes` for
> notification threshold logic.
- return after empty terminal prompt
- resolve `.` and `..` in terminal `cd` and prevent panic in path elide
> `absolutize` now lexically normalizes paths so `cd ..` navigates to the
> parent directory instead of appending `..` literally. Also guard against
> `file_name()` returning `None` in `fmt_path_elide_ex`, which caused a
> panic on paths containing unresolved `..` components.
- pass full command string to exec, not just the first word
> The `Exec` arm of `Command::from_str` only captured the first
> whitespace-delimited token, silently dropping all arguments.
> Now passes the entire input string so e.g. `ls -la /tmp` works.
- sync browsing when entering a directory from filtered/fuzzy view
- stabilize core error handling
> Remove production panic and unwrap paths from core modules.
>
> Propagate bookmark encryption failures, harden file watcher and temp mapped file handling, and clean up dead code in shared utilities.
- normalize localhost relative path checks
- use time-based redraw interval instead of progress-delta threshold
> The old 1% progress threshold caused the UI to appear frozen on large
> files (e.g. 1GB) because many read/write iterations passed between
> redraws. Switching to a 100ms time-based interval ensures consistent
> UI responsiveness regardless of file size.
- render progress bar immediately after mounting
> Call self.view() right after mount_progress_bar() at all 6 call sites
> so the bar is visible on screen before the transfer loop begins.
- filter self-references and dot entries from remote directory listings
> Some non-compliant FTP servers (e.g. LiteSpeed) include a self-reference
> to the listed directory in the LIST response, causing the current folder
> to appear as a duplicate entry in the explorer.
### Miscellaneous
- Use apt to install termscp on debian base to prevent broken deps
- Copilot is so dumb
- Install with apt if installed
- WTF CTRL SAVE DOESNT FUCKING WORK
- funding
- add centralized lint configuration to Cargo.toml
- 1.0.0 version into manifests
- format toml
- add fmt+clippy convention to CLAUDE.md and apply nightly fmt
- enable safe clippy pedantic lints
### Performance
- use sort_by_cached_key to avoid repeated lowercase allocations in file sorting
### Testing
- add parser and bookmark regression coverage
- extend config and explorer regression coverage
- extend system regression coverage
### Build
- removed `hostname`, use `whoami` instead
> whoami provides `hostname` function, so we don't need the hostname dependency, since whoami is also being used for getting the username
- replaced libssh with russh for remotefs-ssh
- replace `version-compare` crate with `semver`
- remotefs-ssh 0.8.1
- remotefs-ssh 0.8.2
> this version removes any usage of sh commands from the sftp backend and only uses pure protocol functions
- migrate to tui-realm 4.0
> Upgrade tuirealm (3.x -> 4.0.0), tui-realm-stdlib (3 -> 4), tui-term
> (0.2 -> 0.3). Apply all breaking changes from the 4.0 migration guide
> across the termscp UI.
>
> Key changes:
>
> - Root-level re-exports removed; imports moved to module-qualified
> paths (`tuirealm::application`, `::component`, `::event`, `::props`,
> `::state`, `::subscription`, `::listener`, `::ratatui`). Same for
> stdlib component types (`tui_realm_stdlib::components::*`).
> - `MockComponent` trait renamed to `Component`; old `Component` trait
> renamed to `AppComponent`. `#[derive(MockComponent)]` is now
> `#[derive(Component)]`. `Component::on` now takes `&Event<_>`.
> - `TextSpan` replaced with `SpanStatic`/`LineStatic`/`TextStatic`
> (ratatui-based); tuple `(String, Alignment)` titles replaced with
> the new `Title` builder; `Alignment` split into
> `HorizontalAlignment`/`VerticalAlignment`; stdlib components use
> `.alignment_horizontal` instead of `.alignment`.
> - `State::One`/`PropPayload::One` -> `Single`. `CmdResult::None`
> -> `NoChange`. `Props::get_or` removed; `Props::get` now returns a
> borrowed `Option<&AttrValue>` (call sites switched to
> `.and_then(AttrValue::as_*)`). `Component::query` returns
> `Option<QueryResult<'a>>`.
> - `Attribute::HighlightedColor` -> `HighlightStyle` (a full `Style`).
> `.highlighted_*` helpers renamed to `.highlight_*`.
> - `PollStrategy::UpTo(n)` now requires a `Duration`; tick timeout moved
> from `EventListenerCfg::poll_timeout` into `PollStrategy`.
> - `TerminalBridge` removed; `Context` now holds
> `CrosstermTerminalAdapter` directly and enables raw mode + alternate
> screen explicitly. The `TerminalAdapter` trait is imported where its
> methods are used.
> - `Update` trait removed; activity `update` methods are plain inherent
> functions.
> - `ProgressBar` replaced by stdlib `Gauge`. Paragraph `.wrap` renamed
> to `.wrap_trim`; `.text` now takes an `Into<Text>`. Stdlib `List` row
> items are now individual lines (`Vec<Span>` per row) rather than a
> `Table` of spans; custom `FileList`/`Log` convert between the two
> models.
> - Radio builders drop `.foreground(color)` so unselected items render
> with the terminal default foreground, and set
> `highlight_style(Style::default().fg(color).add_modifier(REVERSED))`
> so the selected entry is visibly highlighted only with the theme
> color.
> - Custom `FileList` keeps the selected row highlighted with the full
> highlight style when focused and falls back to a foreground-only
> style when unfocused.
> - Theme loading is now backwards compatible: `Theme` uses a custom
> `Deserialize` through an intermediate `ThemeFile` with optional
> fields, so missing keys, unknown values or legacy aliases
> (`transfer_progress_bar_full`/`_partial`) fall back to defaults on a
> per-field basis instead of failing the whole load.
- upgrade remotefs-ssh to 0.8.3
## 0.19.1
Released on 2026-04-18
- [Issue 371](https://github.com/veeso/termscp/issues/371): Updated dependencies to allow build on NetBSD
## 0.19.0
Released on 11/11/2025
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
termscp is a terminal file transfer client with a TUI (Terminal User Interface), supporting SFTP, SCP, FTP/FTPS, Kube, S3, SMB, and WebDAV protocols. It features a dual-pane file explorer (local + remote), bookmarks, system keyring integration, file watching/sync, an embedded terminal, and customizable themes.
- **Language**: Rust (edition 2024, MSRV 1.89.0)
- **UI Framework**: tuirealm v3 (built on crossterm)
- **File Transfer**: remotefs ecosystem
## Build & Development Commands
```bash
# Build
cargo build
cargo build --release
cargo build --no-default-features # minimal build without SMB/keyring
# Test (CI-equivalent)
cargo test --no-default-features --features github-actions --no-fail-fast
# Run a single test
cargo test <test_name> -- --nocapture
# Run tests for a module
cargo test --lib filetransfer::
cargo test --lib config::params::tests
# Lint
cargo clippy -- -Dwarnings
# Format
cargo fmt --all -- --check # check only
cargo fmt --all # fix
```
### System Dependencies (for building)
- **Linux**: `libdbus-1-dev`, `libsmbclient-dev`
- **macOS**: `pkg-config`, `samba` (brew, with force link)
## Feature Flags
- **`smb`** (default): SMB/Samba protocol support
- **`keyring`** (default): System keyring integration for password storage
- **`smb-vendored`**: Vendored SMB library (for static builds)
- **`github-actions`**: CI flag — disables real keyring in tests, uses file-based storage
- **`isolated-tests`**: For parallel test isolation
## Architecture
### Application Lifecycle
```
main.rs → parse CLI args → ActivityManager::new() → ActivityManager::run()
Activity loop (draw → poll → update)
├── AuthActivity (login/bookmarks)
├── FileTransferActivity (dual-pane explorer)
└── SetupActivity (configuration)
```
`ActivityManager` owns a `Context` that is passed between activities. Each activity takes ownership of the Context on `on_create()` and returns it on `on_destroy()`.
### Key Modules
| Module | Path | Purpose |
|--------|------|---------|
| **activity_manager** | `src/activity_manager.rs` | Orchestrates activity lifecycle and transitions |
| **ui/activities** | `src/ui/activities/{auth,filetransfer,setup}/` | Three main screens, each implementing the `Activity` trait |
| **ui/context** | `src/ui/context.rs` | Shared `Context` struct (terminal, config, bookmarks, theme) |
| **filetransfer** | `src/filetransfer/` | Protocol enum, `RemoteFsBuilder`, connection parameters |
| **host** | `src/host/` | `HostBridge` trait — abstracts local (`Localhost`) and remote (`RemoteBridged`) file operations |
| **explorer** | `src/explorer/` | `FileExplorer` — directory navigation, sorting, filtering, transfer queue |
| **system** | `src/system/` | `BookmarksClient`, `ConfigClient`, `ThemeProvider`, `SshKeyStorage`, `KeyStorage` trait |
| **config** | `src/config/` | TOML-based serialization for themes, bookmarks, user params |
### Core Traits
- **`Activity`** (`src/ui/activities/mod.rs`): `on_create`, `on_draw`, `will_umount`, `on_destroy` — UI screen lifecycle
- **`HostBridge`** (`src/host/bridge.rs`): Unified file operations interface (connect, list_dir, open_file, mkdir, remove, rename, copy, etc.)
- **`KeyStorage`** (`src/system/keys/mod.rs`): `get_key`/`set_key` — password storage abstraction (keyring or encrypted file fallback)
### Conditional Compilation
The `build.rs` defines cfg aliases via `cfg_aliases`:
- `posix`, `macos`, `linux`, `win` — platform shortcuts
- `smb`, `smb_unix`, `smb_windows` — feature + platform combinations
Platform-specific dependencies: SSH and FTP crates use different TLS backends on Unix vs Windows. SMB support is completely gated behind the `smb` feature flag.
### File Transfer Protocols
`FileTransferProtocol` enum maps to protocol-specific parameter types (`ProtocolParams` enum) and `RemoteFsBuilder` constructs the appropriate `RemoteFs` client. Each protocol has its own params struct (e.g., `GenericProtocolParams` for SSH-based, `AwsS3Params`, `KubeProtocolParams`, `SmbParams`, `WebDAVProtocolParams`).
## Code Conventions
- **rustfmt**: `group_imports = "StdExternalCrate"`, `imports_granularity = "Module"`
- **Error handling**: Custom error types with `thiserror`, module-level Result aliases (e.g., `HostResult<T>`)
- **Builder pattern**: Used for `RemoteFsBuilder`, `HostBridgeBuilder`
- **Client pattern**: System services wrapped as clients (`BookmarksClient`, `ConfigClient`)
- **Tests**: Unit tests in `#[cfg(test)]` blocks within source files. Tests requiring serial execution use `#[serial]` from `serial_test`
- **Encryption**: Bookmark passwords encrypted with `magic-crypt`; keys stored in system keyring or encrypted file
## Other conventions
- Always run `cargo +nightly fmt --all` and `cargo clippy --no-default-features -- -Dwarnings` after modifying Rust code
- Always put plans to `./.claude/plans/`
+1 -1
View File
@@ -138,7 +138,7 @@ Let's make it simple and clear:
In addition to the process described for the PRs, I've also decided to introduce a list of guidelines to follow when writing the code, that should be followed:
1. **Let's stop the NPM apocalypse**: personally I'm against the abuse of dependencies we make in software projects and I think that NodeJS has opened the way to this drama (and has already gone too far). Nowadays nobody cares about adding hundreds of dependencies to their projects. Don't misunderstand me: I think that package managers are cool, but I'm totally against the abuse we're making of them. I think when we work on a project, we should try to use the minor quantity of dependencies as possible, especially because it's not hard to see how many libraries are getting abandoned right now, causing compatibility issues after a while. So please, when working on termscp, try not to add useless dependencies.
2. **No C-bindings**: personally I think that Rust still relies too much on C. And that's bad, really bad. Many libraries in Rust are just wrappers to C libraries, which is a huge problem, especially considering this is a multiplatform project. Everytime you add a C-binding to your project, you're forcing your users to install additional libraries to their systems. Sometimes these libraries are already installed on their systems (as happens for libssh2 or openssl in this case), but sometimes not. So if you really have to add a dependency to this project, please AVOID completely adding C-bounded libraries.
2. **No C-bindings**: personally I think that Rust still relies too much on C. And that's bad, really bad. Many libraries in Rust are just wrappers to C libraries, which is a huge problem, especially considering this is a multiplatform project. Everytime you add a C-binding to your project, you're forcing your users to install additional libraries to their systems. Sometimes these libraries are already installed on their systems (as happens for openssl in this case), but sometimes not. So if you really have to add a dependency to this project, please AVOID completely adding C-bounded libraries.
3. **Test units matter**: Whenever you implement something new to this project, always implement test units which cover the most cases as possible.
4. **Comments are useful**: Many people say that the code should be that simple to talk by itself about what it does, and comments should then be useless. I personally don't agree. I'm not saying they're wrong, but I'm just saying that this approach has, in my personal opinion, many aspects which are underrated:
1. What's obvious for me, might not be for the others.
Generated
+2455 -960
View File
File diff suppressed because it is too large Load Diff
+106 -91
View File
@@ -1,17 +1,17 @@
[package]
authors = ["Christian Visintin <christian.visintin@veeso.dev>"]
categories = ["command-line-utilities"]
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV"
name = "termscp"
version = "1.0.0"
edition = "2024"
authors = ["Christian Visintin <christian.visintin@veeso.dev>"]
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV"
license = "MIT"
repository = "https://github.com/veeso/termscp"
categories = ["command-line-utilities"]
homepage = "https://termscp.veeso.dev"
include = ["src/**/*", "build.rs", "LICENSE", "README.md", "CHANGELOG.md"]
keywords = ["terminal", "ftp", "scp", "sftp", "tui"]
license = "MIT"
name = "termscp"
readme = "README.md"
repository = "https://github.com/veeso/termscp"
version = "0.19.0"
rust-version = "1.88.0"
rust-version = "1.89.0"
[package.metadata.rpm]
package = "termscp"
@@ -27,96 +27,111 @@ maintainer = "Christian Visintin <christian.visintin@veeso.dev>"
copyright = "2025, Christian Visintin <christian.visintin@veeso.dev>"
extended-description-file = "docs/misc/README.deb.txt"
[[bin]]
name = "termscp"
path = "src/main.rs"
[dependencies]
argh = "^0.1"
bitflags = "^2"
bytesize = "^2"
chrono = "^0.4"
content_inspector = "^0.2"
dirs = "^6"
edit = "^0.1"
filetime = "^0.2"
hostname = "^0.4"
keyring = { version = "^3", features = [
"apple-native",
"windows-native",
"sync-secret-service",
"vendored",
] }
lazy-regex = "^3"
lazy_static = "^1"
log = "^0.4"
magic-crypt = "4"
notify = "8"
notify-rust = { version = "^4", default-features = false, features = ["d"] }
nucleo = "0.5"
open = "^5.0"
rand = "^0.9"
regex = "^1"
remotefs = "^0.3"
remotefs-aws-s3 = "0.4"
remotefs-kube = "0.4"
remotefs-smb = { version = "^0.3", optional = true }
remotefs-webdav = "^0.2"
rpassword = "^7"
self_update = { version = "^0.42", default-features = false, features = [
"rustls",
"archive-tar",
"archive-zip",
"compression-flate2",
"compression-zip-deflate",
] }
serde = { version = "^1", features = ["derive"] }
shellexpand = "3"
simplelog = "^0.12"
ssh2-config = "^0.6"
tempfile = "3"
thiserror = "2"
tokio = { version = "1", features = ["rt"] }
toml = "^0.9"
tui-realm-stdlib = "3"
tuirealm = "3"
tui-term = "0.2"
unicode-width = "^0.2"
version-compare = "^0.2"
whoami = "^1.6"
wildmatch = "^2"
[target."cfg(target_family = \"unix\")".dependencies]
remotefs-ftp = { version = "^0.3", features = [
"native-tls-vendored",
"native-tls",
] }
remotefs-ssh = { version = "^0.7", default-features = false, features = [
"find",
"libssh-vendored",
] }
uzers = "0.12"
[target."cfg(target_family = \"windows\")".dependencies]
remotefs-ftp = { version = "^0.3", features = ["native-tls"] }
remotefs-ssh = { version = "^0.7" }
[dev-dependencies]
pretty_assertions = "^1"
serial_test = "^3"
[build-dependencies]
cfg_aliases = "0.2"
vergen-git2 = { version = "1", features = ["build", "cargo", "rustc", "si"] }
[features]
default = ["smb", "keyring"]
default = ["keyring", "smb"]
github-actions = []
isolated-tests = []
keyring = []
smb = ["dep:remotefs-smb"]
smb-vendored = ["remotefs-smb/vendored"]
[dependencies]
aes = "0.8"
aes-gcm = "0.10"
argh = "0.1"
base64 = "0.22"
bitflags = "2"
bytesize = "2"
cbc = { version = "0.1", features = ["alloc"] }
chrono = "0.4"
content_inspector = "0.2"
dirs = "6"
edit = "0.1"
filetime = "0.2"
keyring = { version = "3", features = [
"apple-native",
"sync-secret-service",
"vendored",
"windows-native",
] }
lazy-regex = "3"
log = "0.4"
md-5 = "0.10"
notify = "8"
notify-rust = { version = "4", default-features = false, features = ["d"] }
nucleo = "0.5"
open = "5"
rand = "0.9"
regex = "1"
remotefs = "0.3"
remotefs-aws-s3 = "0.4"
remotefs-kube = "0.4"
remotefs-smb = { version = "0.3", optional = true }
remotefs-ssh = { version = "0.8", default-features = false, features = ["russh"] }
remotefs-webdav = "0.2"
rpassword = "7"
self_update = { version = "0.42", default-features = false, features = [
"archive-tar",
"archive-zip",
"compression-flate2",
"compression-zip-deflate",
"rustls",
] }
semver = "1"
serde = { version = "1", features = ["derive"] }
shellexpand = "3"
simplelog = "0.12"
ssh2-config = "0.7"
tempfile = "3"
thiserror = "2"
tokio = { version = "1", features = ["rt"] }
toml = "1"
tui-realm-stdlib = "4"
tui-term = "0.3"
tuirealm = "4"
unicode-width = "0.2"
whoami = "2"
wildmatch = "2"
[target."cfg(target_family = \"unix\")".dependencies]
remotefs-ftp = { version = "0.4", features = [
"native-tls",
"native-tls-vendored",
] }
uzers = "0.12"
[target."cfg(target_family = \"windows\")".dependencies]
remotefs-ftp = { version = "0.4", features = ["native-tls"] }
[dev-dependencies]
pretty_assertions = "1"
serial_test = "3"
[build-dependencies]
cfg_aliases = "0.2"
vergen-git2 = { version = "9", features = ["build", "cargo", "rustc", "si"] }
[[bin]]
name = "termscp"
path = "src/main.rs"
[lints.rust]
trivial_numeric_casts = "warn"
unsafe_op_in_unsafe_fn = "warn"
unused_lifetimes = "warn"
[lints.clippy]
complexity = { level = "warn", priority = -1 }
correctness = { level = "warn", priority = -1 }
cloned_instead_of_copied = "warn"
implicit_clone = "warn"
manual_string_new = "warn"
perf = { level = "warn", priority = -1 }
redundant_closure_for_method_calls = "warn"
style = { level = "warn", priority = -1 }
suspicious = { level = "warn", priority = -1 }
unnested_or_patterns = "warn"
[profile.dev]
incremental = true
+1 -1
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Developed by <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Current version: 0.19.0 11/11/2025</p>
<p align="center">Current version: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
+47
View File
@@ -0,0 +1,47 @@
[changelog]
body = """
## {{ version | trim_start_matches(pat="v") }}
Released on {{ timestamp | date(format="%Y-%m-%d") }}
{%- if commits | filter(attribute="breaking", value=true) | length > 0 %}
### ⚠ Breaking Changes
{%- for commit in commits | filter(attribute="breaking", value=true) %}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | split(pat="\n") | first | trim }}
{%- if commit.breaking_description %}
> {{ commit.breaking_description }}
{%- endif %}
{%- endfor %}
{%- endif %}
{%- for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{%- for commit in commits %}
- {% if commit.breaking %}💥 {% endif %}{% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | split(pat="\n") | first | trim }}
{%- if commit.body %}
> {{ commit.body | split(pat="\n") | join(sep="\n > ") }}
{%- endif %}
{%- endfor %}
{%- endfor %}
"""
trim = false
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^refactor", group = "Changed" },
{ message = "^perf", group = "Performance" },
{ message = "^doc", group = "Documentation" },
{ message = "^test", group = "Testing" },
{ message = "^ci", group = "CI" },
{ message = "^chore", group = "Miscellaneous" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
-1
View File
@@ -43,7 +43,6 @@ www: \"https://termscp.veeso.dev/termscp/\"\n\
maintainer: \"christian.visintin1997@gmail.com\"\n\
prefix: \"/usr/local/bin\"\n\
deps: {\n\
libssh: {origin: security/libssh, version: 0.9.5}\n\
}\n\
files: {\n\
/usr/local/bin/termscp: \"$HASH\"\n\
+2 -4
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Entwickelt von <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Aktuelle Version: 0.19.0 11/11/2025</p>
<p align="center">Aktuelle Version: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -193,12 +193,10 @@ Für weitere Informationen oder andere Plattformen besuchen Sie bitte [termscp.v
### Softwareanforderungen ❗
- **Linux** Benutzer:
- libssh
- libdbus-1
- pkg-config
- libsmbclient
- **FreeBSD** Benutzer:
- libssh
- dbus
- pkgconf
- libsmbclient
@@ -265,7 +263,7 @@ termscp wird von diesen großartigen Projekten unterstützt:
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-s3](https://github.com/durch/rust-s3)
- [self_update](https://github.com/jaemk/self_update)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [russh](https://github.com/Eugeny/russh)
- [suppaftp](https://github.com/veeso/suppaftp)
- [ratatui](https://github.com/ratatui-org/ratatui)
- [tui-realm](https://github.com/veeso/tui-realm)
+2 -2
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Desarrollado por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versión actual: 0.19.0 11/11/2025</p>
<p align="center">Versión actual: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -263,7 +263,7 @@ termscp funciona con estos increíbles proyectos:
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-s3](https://github.com/durch/rust-s3)
- [self_update](https://github.com/jaemk/self_update)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [russh](https://github.com/Eugeny/russh)
- [suppaftp](https://github.com/veeso/suppaftp)
- [ratatui](https://github.com/ratatui-org/ratatui)
- [tui-realm](https://github.com/veeso/tui-realm)
+2 -2
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Développé par <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Version actuelle: 0.19.0 11/11/2025</p>
<p align="center">Version actuelle: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -265,7 +265,7 @@ termscp est soutenu par ces projets impressionnants:
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-s3](https://github.com/durch/rust-s3)
- [self_update](https://github.com/jaemk/self_update)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [russh](https://github.com/Eugeny/russh)
- [suppaftp](https://github.com/veeso/suppaftp)
- [ratatui](https://github.com/ratatui-org/ratatui)
- [tui-realm](https://github.com/veeso/tui-realm)
+2 -2
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Sviluppato da <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versione corrente: 0.19.0 11/11/2025</p>
<p align="center">Versione corrente: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -263,7 +263,7 @@ se termscp esiste, è anche grazie a questi fantastici progetti:
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-s3](https://github.com/durch/rust-s3)
- [self_update](https://github.com/jaemk/self_update)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [russh](https://github.com/Eugeny/russh)
- [suppaftp](https://github.com/veeso/suppaftp)
- [ratatui](https://github.com/ratatui-org/ratatui)
- [tui-realm](https://github.com/veeso/tui-realm)
+1 -1
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">Desenvolvido por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versão atual: 0.19.0 11/11/2025</p>
<p align="center">Versão atual: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
+2 -4
View File
@@ -71,7 +71,7 @@
</p>
<p align="center">由 <a href="https://veeso.me/" target="_blank">@veeso</a> 开发</p>
<p align="center">当前版本: 0.19.0 11/11/2025</p>
<p align="center">当前版本: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -196,12 +196,10 @@ choco install termscp
### 依赖 ❗
- **Linux** 用户:
- libssh
- libdbus-1
- pkg-config
- libsmbclient
- **FreeBSD** 用户:
- libssh
- dbus
- pkgconf
- libsmbclient
@@ -270,7 +268,7 @@ termscp 由这些很棒的项目提供支持:
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-s3](https://github.com/durch/rust-s3)
- [self_update](https://github.com/jaemk/self_update)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [russh](https://github.com/Eugeny/russh)
- [suppaftp](https://github.com/veeso/suppaftp)
- [ratatui](https://github.com/ratatui-org/ratatui)
- [tui-realm](https://github.com/veeso/tui-realm)
+52 -30
View File
@@ -8,10 +8,10 @@
# -f, -y, --force, --yes
# Skip the confirmation prompt during installation
TERMSCP_VERSION="0.19.0"
TERMSCP_VERSION="1.0.0"
GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb"
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_arm64.deb"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_amd64.deb"
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_arm64.deb"
PATH="$PATH:/usr/sbin"
@@ -33,8 +33,8 @@ NO_COLOR="$(tput sgr0 2>/dev/null || printf '')"
set_termscp_version() {
TERMSCP_VERSION="$1"
GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb"
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_arm64.deb"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_amd64.deb"
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_arm64.deb"
}
info() {
@@ -217,6 +217,49 @@ install_with_brew() {
fi
}
install_on_debian() {
local pkg_manager
if has apt; then
pkg_manager="apt"
elif has "apt-get"; then
pkg_manager="apt-get"
else
pkg_manager="dpkg"
fi
info "Detected $pkg_manager on your system"
case "${ARCH}" in
x86_64) DEB_URL="$DEB_URL_AMD64" ;;
aarch64) DEB_URL="$DEB_URL_AARCH64" ;;
*) try_with_cargo "we don't distribute packages for ${ARCH} at the moment" && return $? ;;
esac
info "Installing ${GREEN}termscp${NO_COLOR} via Debian package"
archive=$(get_tmpfile "deb")
download "${archive}" "${DEB_URL}"
info "Downloaded debian package to ${archive}"
if test_writeable "/usr/bin"; then
sudo=""
msg="Installing ${GREEN}termscp${NO_COLOR}, please wait…"
else
warn "Root permissions are required to install ${GREEN}termscp${NO_COLOR}"
elevate_priv
sudo="sudo"
msg="Installing ${GREEN}termscp${NO_COLOR} as root, please wait…"
fi
info "$msg"
if [ "$pkg_manager" = "apt" ]; then
$sudo apt install -y "${archive}"
elif [ "$pkg_manager" = "apt-get" ]; then
$sudo dpkg -i "${archive}"
$sudo apt-get -f install
else
$sudo dpkg -i "${archive}"
fi
rm -f ${archive}
}
install_on_linux() {
local msg
local sudo
@@ -236,28 +279,7 @@ install_on_linux() {
elif has pikaur; then
install_on_arch_linux pikaur
elif has dpkg; then
case "${ARCH}" in
x86_64) DEB_URL="$DEB_URL_AMD64" ;;
aarch64) DEB_URL="$DEB_URL_AARCH64" ;;
*) try_with_cargo "we don't distribute packages for ${ARCH} at the moment" && return $? ;;
esac
info "Detected dpkg on your system"
info "Installing ${GREEN}termscp${NO_COLOR} via Debian package"
archive=$(get_tmpfile "deb")
download "${archive}" "${DEB_URL}"
info "Downloaded debian package to ${archive}"
if test_writeable "/usr/bin"; then
sudo=""
msg="Installing ${GREEN}termscp${NO_COLOR}, please wait…"
else
warn "Root permissions are required to install ${GREEN}termscp${NO_COLOR}"
elevate_priv
sudo="sudo"
msg="Installing ${GREEN}termscp${NO_COLOR} as root, please wait…"
fi
info "$msg"
$sudo dpkg -i "${archive}"
rm -f ${archive}
install_on_debian
elif has brew; then
install_with_brew
else
@@ -277,9 +299,9 @@ install_on_macos() {
install_bsd_cargo_deps() {
set -e
confirm "${YELLOW}libssh, gcc${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}; would you like to proceed?"
confirm "${YELLOW}gcc${NO_COLOR} is required to install ${GREEN}termscp${NO_COLOR}; would you like to proceed?"
sudo="$(elevate_priv_ex /usr/local/bin)"
$sudo pkg install -y curl wget libssh gcc dbus pkgconf libsmbclient
$sudo pkg install -y curl wget gcc dbus pkgconf libsmbclient
info "Dependencies installed successfully"
}
@@ -305,7 +327,7 @@ install_linux_cargo_deps() {
exit 1
fi
set -e
confirm "${YELLOW}libssh, gcc, openssl, pkg-config, libdbus${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}. The following command will be used to install the dependencies: '${BOLD}${YELLOW}${deps_cmd}${NO_COLOR}'. Would you like to proceed?"
confirm "${YELLOW}gcc, openssl, pkg-config, libdbus${NO_COLOR} are required to install ${GREEN}termscp${NO_COLOR}. The following command will be used to install the dependencies: '${BOLD}${YELLOW}${deps_cmd}${NO_COLOR}'. Would you like to proceed?"
sudo="$(elevate_priv_ex /usr/local/bin)"
$sudo $deps_cmd
info "Dependencies installed successfully"
+2 -2
View File
@@ -35,7 +35,7 @@
<span translate="getStarted.windows.moderation">Consider that Chocolatey moderation can take up to a few weeks
since last release, so if the latest version is not available yet,
you can install it downloading the ZIP file from</span>
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.0.19.0.nupkg"
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.1.0.0.nupkg"
target="_blank">Github</a>
<span translate="getStarted.windows.then">and then, from the ZIP directory, install it via</span>
</p>
@@ -74,7 +74,7 @@
On Debian based distros, you can install termscp using the Deb
package via:
</p>
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_0.19.0_amd64.deb</span>
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_1.0.0_amd64.deb</span>
sudo <span class="function">dpkg</span> -i <span class="string">termscp.deb</span></pre>
</div>
<h3>
+1 -1
View File
@@ -12,7 +12,7 @@
</button>
<div class="p-4 my-4 text-sm text-green-800 rounded-lg bg-green-50">
<p class="text-lg">
<span translate="intro.versionAlert">termscp 0.19.0 is NOW out! Download it from</span>&nbsp;
<span translate="intro.versionAlert">termscp 1.0.0 is NOW out! Download it from</span>&nbsp;
<a href="/get-started.html" translate="intro.here">here!</a>
</p>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Get started →",
"versionAlert": "termscp 0.19.0 is NOW out! Download it from",
"versionAlert": "termscp 1.0.0 is NOW out! Download it from",
"here": "here",
"features": {
"handy": {
+1 -1
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "Un explorador y transferencia de archivos de terminal rico en funciones, con apoyo para SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Para iniciar →",
"versionAlert": "termscp 0.19.0 ya está disponible! Descárgalo desde",
"versionAlert": "termscp 1.0.0 ya está disponible! Descárgalo desde",
"here": "aquì",
"features": {
"handy": {
+1 -1
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "Un file transfer et navigateur de terminal riche en fonctionnalités avec support pour SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Pour commencer →",
"versionAlert": "termscp 0.19.0 est maintenant sorti! Télécharge-le depuis",
"versionAlert": "termscp 1.0.0 est maintenant sorti! Télécharge-le depuis",
"here": "ici",
"features": {
"handy": {
+1 -1
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "Un file transfer ed explorer ricco di funzionalità con supporto per SFTP/SCP/FTP/S3",
"getStarted": "Installa termscp →",
"versionAlert": "termscp 0.19.0 è ORA disponbile! Scaricalo da",
"versionAlert": "termscp 1.0.0 è ORA disponbile! Scaricalo da",
"here": "qui",
"features": {
"handy": {
+1 -1
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "功能丰富的终端 UI 文件传输和浏览器,支持 SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "开始 →",
"versionAlert": "termscp 0.19.0 现已发布! 从下载",
"versionAlert": "termscp 1.0.0 现已发布! 从下载",
"here": "这里",
"features": {
"handy": {
+37 -21
View File
@@ -94,7 +94,9 @@ impl ActivityManager {
// local dir is remote_args.local_dir if set, otherwise current dir
let local_dir = remote_args
.local_dir
.unwrap_or_else(|| env::current_dir().unwrap());
.map(Ok)
.unwrap_or_else(env::current_dir)
.map_err(|err| format!("Could not resolve current directory: {err}"))?;
debug!("host bridge is None, setting local dir to {:?}", local_dir,);
self.set_host_params(
@@ -143,27 +145,26 @@ impl ActivityManager {
match host {
HostParams::HostBridge(HostBridgeParams::Localhost(path)) => {
self.context
.as_mut()
.unwrap()
self.context_mut()?
.set_host_bridge_params(HostBridgeParams::Localhost(path));
}
HostParams::HostBridge(HostBridgeParams::Remote(_, _)) => {
let (protocol, params) = remote_params.unwrap();
self.context
.as_mut()
.unwrap()
let (protocol, params) = remote_params.ok_or_else(|| {
String::from("Missing remote parameters for host bridge configuration")
})?;
self.context_mut()?
.set_host_bridge_params(HostBridgeParams::Remote(protocol, params));
}
HostParams::Remote(_) => {
let (protocol, params) = remote_params.unwrap();
let (protocol, params) = remote_params
.ok_or_else(|| String::from("Missing remote parameters for remote host"))?;
let params = FileTransferParams {
local_path: remote_local_path,
remote_path: remote_remote_path,
protocol,
params,
};
self.context.as_mut().unwrap().set_remote_params(params);
self.context_mut()?.set_remote_params(params);
}
}
Ok(())
@@ -185,16 +186,19 @@ impl ActivityManager {
) && params.generic_params().is_some()
{
// * if protocol is SCP or SFTP check whether a SSH key is registered for this remote, in case not ask password
let storage = SshKeyStorage::from(self.context.as_ref().unwrap().config());
let generic_params = params.generic_params().unwrap();
let storage = SshKeyStorage::from(self.context_ref()?.config());
let generic_params = params.generic_params().ok_or_else(|| {
String::from("Missing generic parameters for SSH password resolution")
})?;
let username = generic_params
.username
.clone()
.map(Ok)
.unwrap_or_else(whoami::username)
.map_err(|err| format!("Could not get current username: {err}"))?;
if storage
.resolve(
&generic_params.address,
&generic_params
.username
.clone()
.unwrap_or(whoami::username()),
)
.resolve(&generic_params.address, &username)
.is_none()
{
debug!(
@@ -218,7 +222,7 @@ impl ActivityManager {
/// Prompt user for password to set into params.
fn prompt_password(&mut self, params: &mut ProtocolParams) -> Result<(), String> {
let ctx = self.context.as_mut().unwrap();
let ctx = self.context_mut()?;
let prompt = format!("Password for {}: ", params.host_name());
match tty::read_secret_from_tty(ctx.terminal(), prompt) {
@@ -243,7 +247,7 @@ impl ActivityManager {
bookmark_name: &str,
password: Option<&str>,
) -> Result<(), String> {
if let Some(bookmarks_client) = self.context.as_mut().unwrap().bookmarks_client_mut() {
if let Some(bookmarks_client) = self.context_mut()?.bookmarks_client_mut() {
let params = match bookmarks_client.get_bookmark(bookmark_name) {
None => {
return Err(format!(
@@ -268,6 +272,18 @@ impl ActivityManager {
}
}
fn context_mut(&mut self) -> Result<&mut Context, String> {
self.context
.as_mut()
.ok_or_else(|| String::from("Activity manager context is not initialized"))
}
fn context_ref(&self) -> Result<&Context, String> {
self.context
.as_ref()
.ok_or_else(|| String::from("Activity manager context is not initialized"))
}
///
/// Loop for activity manager. You need to provide the activity to start with
/// Returns the exitcode
+19 -6
View File
@@ -1,3 +1,8 @@
//! ## Remote CLI Arguments
//!
//! Parses positional and bookmark-based CLI arguments into the normalized remote
//! connection parameters used by the application.
use std::path::{Path, PathBuf};
use super::Args;
@@ -13,8 +18,11 @@ enum AddrType {
/// Args for remote connection
#[derive(Debug)]
pub struct RemoteArgs {
/// Optional host bridge selected for the session.
pub host_bridge: Remote,
/// Target remote selected for the session.
pub remote: Remote,
/// Optional local working directory override.
pub local_dir: Option<PathBuf>,
}
@@ -41,9 +49,7 @@ impl TryFrom<&Args> for RemoteArgs {
(_, _) => Err("Too many arguments".to_string()),
}?;
// parse bookmark first
let last_item_index = (args.bookmark.len() + args.positional.len())
.checked_sub(1)
.unwrap_or_default();
let last_item_index = (args.bookmark.len() + args.positional.len()).saturating_sub(1);
let mut hosts = vec![];
@@ -75,10 +81,16 @@ impl TryFrom<&Args> for RemoteArgs {
// set args based on hosts len
if hosts.len() == 1 {
remote_args.remote = hosts.pop().unwrap();
remote_args.remote = hosts
.pop()
.ok_or_else(|| String::from("Missing remote host configuration"))?;
} else if hosts.len() == 2 {
remote_args.host_bridge = hosts.pop().unwrap();
remote_args.remote = hosts.pop().unwrap();
remote_args.host_bridge = hosts
.pop()
.ok_or_else(|| String::from("Missing host-bridge configuration"))?;
remote_args.remote = hosts
.pop()
.ok_or_else(|| String::from("Missing remote host configuration"))?;
}
Ok(remote_args)
@@ -105,6 +117,7 @@ pub enum Remote {
}
impl Remote {
/// Returns whether this CLI slot was left unspecified.
pub fn is_none(&self) -> bool {
matches!(self, Self::None)
}
View File
+11
View File
@@ -1,3 +1,7 @@
//! ## Bookmark S3 Parameters
//!
//! Stores the bookmark-specific representation of AWS S3 connection settings.
use serde::{Deserialize, Serialize};
use crate::filetransfer::params::AwsS3Params;
@@ -5,13 +9,20 @@ use crate::filetransfer::params::AwsS3Params;
/// Connection parameters for Aws s3 protocol
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
pub struct S3Params {
/// Bucket name to open.
pub bucket: String,
/// AWS region used for the bucket.
pub region: Option<String>,
/// Custom endpoint URL for S3-compatible services.
pub endpoint: Option<String>,
/// Shared credentials profile name.
pub profile: Option<String>,
/// Static access key identifier.
pub access_key: Option<String>,
/// Static secret access key.
pub secret_access_key: Option<String>,
/// NOTE: there are no session token and security token since they are always temporary
/// Whether to force path-style bucket addressing.
pub new_path_style: Option<bool>,
}
+9
View File
@@ -1,3 +1,7 @@
//! ## Bookmark Kube Parameters
//!
//! Stores bookmark-specific Kubernetes connection settings.
use serde::{Deserialize, Serialize};
use crate::filetransfer::params::KubeProtocolParams;
@@ -5,10 +9,15 @@ use crate::filetransfer::params::KubeProtocolParams;
/// Extra Connection parameters for Kube protocol
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
pub struct KubeParams {
/// Optional default namespace.
pub namespace: Option<String>,
/// Optional cluster API URL.
pub cluster_url: Option<String>,
/// Optional Kubernetes username override.
pub username: Option<String>,
/// Optional client certificate path.
pub client_cert: Option<String>,
/// Optional client key path.
pub client_key: Option<String>,
}
+6
View File
@@ -1,3 +1,7 @@
//! ## Bookmark SMB Parameters
//!
//! Stores bookmark-specific SMB share configuration.
use serde::{Deserialize, Serialize};
use crate::filetransfer::params::SmbParams as TransferSmbParams;
@@ -5,7 +9,9 @@ use crate::filetransfer::params::SmbParams as TransferSmbParams;
/// Extra Connection parameters for SMB protocol
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
pub struct SmbParams {
/// SMB share name.
pub share: String,
/// Optional SMB workgroup used on POSIX platforms.
pub workgroup: Option<String>,
}
+65 -3
View File
@@ -442,6 +442,34 @@ mod tests {
assert!(deserialize::<UserHosts>(Box::new(toml_file)).is_err());
}
#[test]
fn test_should_deserialize_webdav_bookmark_protocol_alias() {
let toml_file: tempfile::NamedTempFile = create_http_alias_toml_bookmarks();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
let host = hosts.bookmarks.get("webdav").unwrap();
assert_eq!(host.protocol, FileTransferProtocol::WebDAV);
assert_eq!(host.address.as_deref(), Some("https://myserver:4445"));
assert_eq!(host.username.as_deref(), Some("omar"));
assert_eq!(host.password.as_deref(), Some("mypassword"));
assert_eq!(
host.remote_path.as_deref(),
Some(std::path::Path::new("/myshare/dir/subdir"))
);
}
#[test]
fn test_should_fail_deserialize_bookmark_with_invalid_protocol() {
let toml_file: tempfile::NamedTempFile = create_invalid_protocol_toml_bookmarks();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
assert!(deserialize::<UserHosts>(Box::new(toml_file)).is_err());
}
#[test]
fn test_config_serializer_bookmarks_serializer_serialize() {
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(2);
@@ -583,10 +611,12 @@ mod tests {
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
assert!(deserialize::<Theme>(Box::new(toml_file)).is_ok());
// Malformed theme files must still load successfully; unknown or invalid
// fields fall back to defaults so user themes remain backwards compatible.
let toml_file = create_bad_toml_theme();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().rewind().unwrap();
assert!(deserialize::<Theme>(Box::new(toml_file)).is_err());
assert!(deserialize::<Theme>(Box::new(toml_file)).is_ok());
}
#[test]
@@ -693,6 +723,39 @@ mod tests {
tmpfile
}
fn create_http_alias_toml_bookmarks() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[bookmarks]
[bookmarks.webdav]
protocol = "HTTPS"
address = "https://myserver:4445"
username = "omar"
password = "mypassword"
directory = "/myshare/dir/subdir"
[recents]
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
fn create_invalid_protocol_toml_bookmarks() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[bookmarks]
[bookmarks.broken]
protocol = "GOPHER"
address = "gopher://myserver"
[recents]
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
fn create_good_toml_theme() -> tempfile::NamedTempFile {
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r##"auth_address = "Yellow"
@@ -714,8 +777,7 @@ mod tests {
transfer_local_explorer_highlighted = "Yellow"
transfer_log_background = "255, 255, 255"
transfer_log_window = "LightGreen"
transfer_progress_bar_full = "forestgreen"
transfer_progress_bar_partial = "Green"
transfer_progress_bar = "forestgreen"
transfer_remote_explorer_background = "#f0f0f0"
transfer_remote_explorer_foreground = "rgb(40, 40, 40)"
transfer_remote_explorer_highlighted = "LightBlue"
+208 -126
View File
@@ -12,145 +12,62 @@ use crate::utils::fmt::fmt_color;
use crate::utils::parser::parse_color;
/// Theme contains all the colors lookup table for termscp
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Theme {
// -- auth
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_address: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_bookmarks: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_password: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_port: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_protocol: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_recents: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub auth_username: Color,
// -- misc
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_error_dialog: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_info_dialog: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_input_dialog: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_keys: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_quit_dialog: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_save_dialog: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub misc_warn_dialog: Color,
// -- transfer
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_local_explorer_background: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_local_explorer_foreground: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_local_explorer_highlighted: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_log_background: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_log_window: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
pub transfer_progress_bar_full: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
pub transfer_progress_bar_partial: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_progress_bar: Color,
#[serde(serialize_with = "serialize_color")]
pub transfer_remote_explorer_background: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_remote_explorer_foreground: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_remote_explorer_highlighted: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_status_hidden: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_status_sorting: Color,
#[serde(
deserialize_with = "deserialize_color",
serialize_with = "serialize_color"
)]
#[serde(serialize_with = "serialize_color")]
pub transfer_status_sync_browsing: Color,
}
@@ -176,8 +93,7 @@ impl Default for Theme {
transfer_local_explorer_highlighted: Color::Yellow,
transfer_log_background: Color::Reset,
transfer_log_window: Color::LightGreen,
transfer_progress_bar_partial: Color::Green,
transfer_progress_bar_full: Color::Green,
transfer_progress_bar: Color::Green,
transfer_remote_explorer_background: Color::Reset,
transfer_remote_explorer_foreground: Color::Reset,
transfer_remote_explorer_highlighted: Color::LightBlue,
@@ -189,16 +105,124 @@ impl Default for Theme {
}
// -- deserializer
//
// Custom deserialization: every field is optional and falls back to `Theme::default()`
// when missing or when the supplied color string is invalid. This keeps user themes
// backwards compatible even when fields are added, renamed, or contain typos.
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
// Parse color
match parse_color(&s) {
None => Err(DeError::custom("Invalid color")),
Some(color) => Ok(color),
#[derive(Deserialize, Default)]
#[serde(default)]
struct ThemeFile {
auth_address: Option<String>,
auth_bookmarks: Option<String>,
auth_password: Option<String>,
auth_port: Option<String>,
auth_protocol: Option<String>,
auth_recents: Option<String>,
auth_username: Option<String>,
misc_error_dialog: Option<String>,
misc_info_dialog: Option<String>,
misc_input_dialog: Option<String>,
misc_keys: Option<String>,
misc_quit_dialog: Option<String>,
misc_save_dialog: Option<String>,
misc_warn_dialog: Option<String>,
transfer_local_explorer_background: Option<String>,
transfer_local_explorer_foreground: Option<String>,
transfer_local_explorer_highlighted: Option<String>,
transfer_log_background: Option<String>,
transfer_log_window: Option<String>,
transfer_progress_bar: Option<String>,
// Legacy aliases for the now-consolidated `transfer_progress_bar` field.
transfer_progress_bar_full: Option<String>,
transfer_progress_bar_partial: Option<String>,
transfer_remote_explorer_background: Option<String>,
transfer_remote_explorer_foreground: Option<String>,
transfer_remote_explorer_highlighted: Option<String>,
transfer_status_hidden: Option<String>,
transfer_status_sorting: Option<String>,
transfer_status_sync_browsing: Option<String>,
}
impl ThemeFile {
fn into_theme(self) -> Theme {
let defaults = Theme::default();
fn pick(value: Option<String>, fallback: Color) -> Color {
value.as_deref().and_then(parse_color).unwrap_or(fallback)
}
Theme {
auth_address: pick(self.auth_address, defaults.auth_address),
auth_bookmarks: pick(self.auth_bookmarks, defaults.auth_bookmarks),
auth_password: pick(self.auth_password, defaults.auth_password),
auth_port: pick(self.auth_port, defaults.auth_port),
auth_protocol: pick(self.auth_protocol, defaults.auth_protocol),
auth_recents: pick(self.auth_recents, defaults.auth_recents),
auth_username: pick(self.auth_username, defaults.auth_username),
misc_error_dialog: pick(self.misc_error_dialog, defaults.misc_error_dialog),
misc_info_dialog: pick(self.misc_info_dialog, defaults.misc_info_dialog),
misc_input_dialog: pick(self.misc_input_dialog, defaults.misc_input_dialog),
misc_keys: pick(self.misc_keys, defaults.misc_keys),
misc_quit_dialog: pick(self.misc_quit_dialog, defaults.misc_quit_dialog),
misc_save_dialog: pick(self.misc_save_dialog, defaults.misc_save_dialog),
misc_warn_dialog: pick(self.misc_warn_dialog, defaults.misc_warn_dialog),
transfer_local_explorer_background: pick(
self.transfer_local_explorer_background,
defaults.transfer_local_explorer_background,
),
transfer_local_explorer_foreground: pick(
self.transfer_local_explorer_foreground,
defaults.transfer_local_explorer_foreground,
),
transfer_local_explorer_highlighted: pick(
self.transfer_local_explorer_highlighted,
defaults.transfer_local_explorer_highlighted,
),
transfer_log_background: pick(
self.transfer_log_background,
defaults.transfer_log_background,
),
transfer_log_window: pick(self.transfer_log_window, defaults.transfer_log_window),
transfer_progress_bar: pick(
self.transfer_progress_bar
.or(self.transfer_progress_bar_full)
.or(self.transfer_progress_bar_partial),
defaults.transfer_progress_bar,
),
transfer_remote_explorer_background: pick(
self.transfer_remote_explorer_background,
defaults.transfer_remote_explorer_background,
),
transfer_remote_explorer_foreground: pick(
self.transfer_remote_explorer_foreground,
defaults.transfer_remote_explorer_foreground,
),
transfer_remote_explorer_highlighted: pick(
self.transfer_remote_explorer_highlighted,
defaults.transfer_remote_explorer_highlighted,
),
transfer_status_hidden: pick(
self.transfer_status_hidden,
defaults.transfer_status_hidden,
),
transfer_status_sorting: pick(
self.transfer_status_sorting,
defaults.transfer_status_sorting,
),
transfer_status_sync_browsing: pick(
self.transfer_status_sync_browsing,
defaults.transfer_status_sync_browsing,
),
}
}
}
impl<'de> Deserialize<'de> for Theme {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let file = ThemeFile::deserialize(deserializer)?;
Ok(file.into_theme())
}
}
@@ -211,14 +235,29 @@ where
serializer.serialize_str(s.as_str())
}
// Kept for backwards compatibility with any external callers; no longer used directly
// by serde derive because `Theme` now uses a custom `Deserialize` impl.
#[allow(dead_code)]
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
match parse_color(&s) {
None => Err(DeError::custom("Invalid color")),
Some(color) => Ok(color),
}
}
#[cfg(test)]
mod test {
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_config_themes_default() {
fn should_get_default_theme() {
let theme: Theme = Theme::default();
assert_eq!(theme.auth_address, Color::Yellow);
assert_eq!(theme.auth_bookmarks, Color::LightGreen);
@@ -239,8 +278,7 @@ mod test {
assert_eq!(theme.transfer_local_explorer_highlighted, Color::Yellow);
assert_eq!(theme.transfer_log_background, Color::Reset);
assert_eq!(theme.transfer_log_window, Color::LightGreen);
assert_eq!(theme.transfer_progress_bar_full, Color::Green);
assert_eq!(theme.transfer_progress_bar_partial, Color::Green);
assert_eq!(theme.transfer_progress_bar, Color::Green);
assert_eq!(theme.transfer_remote_explorer_background, Color::Reset);
assert_eq!(theme.transfer_remote_explorer_foreground, Color::Reset);
assert_eq!(theme.transfer_remote_explorer_highlighted, Color::LightBlue);
@@ -248,4 +286,48 @@ mod test {
assert_eq!(theme.transfer_status_sorting, Color::LightYellow);
assert_eq!(theme.transfer_status_sync_browsing, Color::LightGreen);
}
#[test]
fn should_accept_legacy_progress_bar_fields() {
let toml = r#"
auth_protocol = "Yellow"
transfer_progress_bar_full = "Green"
"#;
let theme: Theme = toml::from_str(toml).expect("theme should load");
assert_eq!(theme.auth_protocol, Color::Yellow);
assert_eq!(theme.transfer_progress_bar, Color::Green);
}
#[test]
fn should_ignore_duplicated_legacy_progress_bar_fields() {
let toml = r#"
auth_protocol = "Yellow"
transfer_progress_bar_full = "Green"
transfer_progress_bar_partial = "Red"
"#;
let theme: Theme = toml::from_str(toml).expect("theme should load");
assert_eq!(theme.auth_protocol, Color::Yellow);
// `_full` wins because `transfer_progress_bar` and `_full` are checked first.
assert_eq!(theme.transfer_progress_bar, Color::Green);
}
#[test]
fn should_fall_back_to_defaults_on_invalid_values() {
// Invalid color value should make *that* field fall back to default, without
// breaking the rest of the theme.
let toml = r##"
auth_protocol = "not-a-color"
auth_username = "#ca9ee6"
"##;
let theme: Theme = toml::from_str(toml).expect("theme should load");
assert_eq!(theme.auth_protocol, Theme::default().auth_protocol);
assert_eq!(theme.auth_username, Color::Rgb(202, 158, 230));
}
#[test]
fn should_fall_back_to_defaults_on_missing_fields() {
// Empty file should still produce a theme equal to default.
let theme: Theme = toml::from_str("").expect("theme should load");
assert_eq!(theme, Theme::default());
}
}
+2 -1
View File
@@ -245,7 +245,8 @@ impl FileExplorer {
/// Sort explorer files by their name. All names are converted to lowercase
fn sort_files_by_name(&mut self) {
self.files.sort_by_key(|x: &File| x.name().to_lowercase());
self.files
.sort_by_cached_key(|x: &File| x.name().to_lowercase());
}
/// Sort files by mtime; the newest comes first
+1 -1
View File
@@ -24,7 +24,7 @@ impl FileExplorerBuilder {
/// Take FileExplorer out of builder
pub fn build(&mut self) -> FileExplorer {
self.explorer.take().unwrap()
self.explorer.take().unwrap_or_default()
}
/// Enable HIDDEN_FILES option
+41 -21
View File
@@ -108,7 +108,7 @@ impl CallChainBlock {
None => {
self.next_block = Some(Box::new(CallChainBlock::new(
func, prefix, fmt_len, fmt_extra,
)))
)));
}
Some(block) => block.push(func, prefix, fmt_len, fmt_extra),
}
@@ -375,23 +375,20 @@ impl Formatter {
// Add to cur str, prefix and the key value
//format!("{cur_str}{prefix}{size:10}", size = size.display().si())
} else if fsentry.metadata().symlink.is_some() {
let size = ByteSize(
fsentry
.metadata()
.symlink
.as_ref()
.unwrap()
.to_string_lossy()
.len() as u64,
);
let mut fmt = size.display().si().to_string();
// pad with up to len 10
let pad = 10usize.saturating_sub(fmt.len());
for _ in 0..pad {
fmt.push(' ');
}
match fsentry.metadata().symlink.as_ref() {
Some(symlink) => {
let size = ByteSize(symlink.to_string_lossy().len() as u64);
let mut fmt = size.display().si().to_string();
// pad with up to len 10
let pad = 10usize.saturating_sub(fmt.len());
for _ in 0..pad {
fmt.push(' ');
}
format!("{cur_str}{prefix}{fmt}")
format!("{cur_str}{prefix}{fmt}")
}
None => format!("{cur_str}{prefix} "),
}
} else {
// Add to cur str, prefix and the key value
format!("{cur_str}{prefix} ")
@@ -476,12 +473,14 @@ impl Formatter {
let mut last_index: usize = 0;
// Match fmt str against regex
for regex_match in FMT_KEY_REGEX.captures_iter(fmt_str) {
// Get match index (unwrap is safe, since always exists)
let index: usize = fmt_str.find(&regex_match[0]).unwrap();
let Some(full_match) = regex_match.get(0) else {
continue;
};
let index: usize = full_match.start();
// Get prefix
let prefix: String = String::from(&fmt_str[last_index..index]);
// Increment last index (sum prefix lenght and the length of the key)
last_index += prefix.len() + regex_match[0].len();
last_index += prefix.len() + full_match.as_str().len();
// Match attributes
match FMT_ATTR_REGEX.captures(&regex_match[1]) {
Some(regex_match) => {
@@ -516,7 +515,7 @@ impl Formatter {
match callchain.as_mut() {
None => {
callchain =
Some(CallChainBlock::new(callback, prefix, fmt_len, fmt_extra))
Some(CallChainBlock::new(callback, prefix, fmt_len, fmt_extra));
}
Some(chain_block) => chain_block.push(callback, prefix, fmt_len, fmt_extra),
}
@@ -1027,6 +1026,27 @@ mod tests {
assert_eq!(formatter.fmt(&entry).as_str(), "喵喵喵喵喵喵喵…");
}
#[test]
fn should_ignore_unknown_formatter_keys() {
let entry = File {
path: PathBuf::from("/tmp/foo.txt"),
metadata: Metadata {
accessed: None,
created: None,
modified: None,
file_type: FileType::File,
size: 8192,
symlink: None,
uid: None,
gid: None,
mode: None,
},
};
let formatter: Formatter = Formatter::new("before {UNKNOWN:12} after {NAME:8}");
assert_eq!(formatter.fmt(&entry).as_str(), "before after foo.txt ");
}
/// Dummy formatter, just yelds an 'A' at the end of the current string
fn dummy_fmt(
_fmt: &Formatter,
+8 -2
View File
@@ -1,13 +1,19 @@
//! ## Host Bridge Builder
//!
//! Builds host bridge implementations from persisted host bridge parameters and
//! the active configuration client.
use super::{HostBridgeParams, RemoteFsBuilder};
use crate::host::{HostBridge, Localhost, RemoteBridged};
use crate::system::config_client::ConfigClient;
/// Builds the host-side filesystem bridge used during file transfer sessions.
pub struct HostBridgeBuilder;
impl HostBridgeBuilder {
/// Build Host Bridge from parms
/// Builds a host bridge from serialized parameters.
///
/// if protocol and parameters are inconsistent, the function will return an error.
/// Returns an error when the selected host protocol and parameters are inconsistent.
pub fn build(
params: HostBridgeParams,
config_client: &ConfigClient,
+17 -4
View File
@@ -25,17 +25,17 @@ pub enum HostBridgeParams {
}
impl HostBridgeParams {
pub fn unwrap_protocol_params(&self) -> &ProtocolParams {
pub fn protocol_params(&self) -> Option<&ProtocolParams> {
match self {
HostBridgeParams::Localhost(_) => panic!("Localhost has no protocol params"),
HostBridgeParams::Remote(_, params) => params,
HostBridgeParams::Localhost(_) => None,
HostBridgeParams::Remote(_, params) => Some(params),
}
}
/// Returns the host name for the bridge params
pub fn username(&self) -> Option<String> {
match self {
HostBridgeParams::Localhost(_) => Some(whoami::username()),
HostBridgeParams::Localhost(_) => whoami::username().ok(),
HostBridgeParams::Remote(_, params) => {
params.generic_params().and_then(|p| p.username.clone())
}
@@ -278,6 +278,19 @@ mod test {
use super::*;
#[test]
fn test_protocol_params_on_localhost_returns_none() {
let params = HostBridgeParams::Localhost(PathBuf::from("/tmp"));
assert!(params.protocol_params().is_none());
}
#[test]
fn test_protocol_params_on_remote_returns_some() {
let params =
HostBridgeParams::Remote(FileTransferProtocol::Sftp, ProtocolParams::default());
assert!(params.protocol_params().is_some());
}
#[test]
fn test_filetransfer_params() {
let params: FileTransferParams =
+14
View File
@@ -1,14 +1,28 @@
//! ## AWS S3 Parameters
//!
//! Defines the runtime connection parameters used to build AWS S3 and
//! S3-compatible remote filesystem clients.
/// Connection parameters for AWS S3 protocol
#[derive(Debug, Clone)]
pub struct AwsS3Params {
/// Target bucket name.
pub bucket_name: String,
/// Target region.
pub region: Option<String>,
/// Optional custom endpoint URL.
pub endpoint: Option<String>,
/// Optional shared credentials profile.
pub profile: Option<String>,
/// Optional static access key.
pub access_key: Option<String>,
/// Optional static secret access key.
pub secret_access_key: Option<String>,
/// Optional security token for temporary credentials.
pub security_token: Option<String>,
/// Optional session token for temporary credentials.
pub session_token: Option<String>,
/// Whether to force path-style bucket addressing.
pub new_path_style: bool,
}
+14 -1
View File
@@ -1,22 +1,35 @@
//! ## Kubernetes Parameters
//!
//! Defines the runtime parameters used to construct Kubernetes-backed file
//! transfer clients.
use remotefs_kube::Config;
/// Protocol params used by WebDAV
/// Protocol params used by Kubernetes connections.
#[derive(Debug, Clone)]
pub struct KubeProtocolParams {
/// Optional namespace for the default pod context.
pub namespace: Option<String>,
/// Optional Kubernetes API URL.
pub cluster_url: Option<String>,
/// Optional username override.
pub username: Option<String>,
/// Optional client certificate path.
pub client_cert: Option<String>,
/// Optional client key path.
pub client_key: Option<String>,
}
impl KubeProtocolParams {
/// Kubernetes connections do not use the shared password secret flow.
pub fn set_default_secret(&mut self, _secret: String) {}
/// Kubernetes params never require the generic password prompt.
pub fn password_missing(&self) -> bool {
false
}
/// Converts bookmark/runtime parameters into a `remotefs_kube` config.
pub fn config(self) -> Option<Config> {
if let Some(cluster_url) = self.cluster_url {
let mut config = Config::new(cluster_url.parse().unwrap_or_default());
+11
View File
@@ -1,13 +1,24 @@
//! ## SMB Parameters
//!
//! Defines the runtime connection parameters used to build SMB remote
//! filesystem clients.
/// Connection parameters for SMB protocol
#[derive(Debug, Clone)]
pub struct SmbParams {
/// Hostname or address of the SMB server.
pub address: String,
#[cfg(posix)]
/// SMB service port used on POSIX platforms.
pub port: u16,
/// Share name to mount.
pub share: String,
/// Optional username.
pub username: Option<String>,
/// Optional password.
pub password: Option<String>,
#[cfg(posix)]
/// Optional workgroup used on POSIX platforms.
pub workgroup: Option<String>,
}
+9
View File
@@ -1,16 +1,25 @@
//! ## WebDAV Parameters
//!
//! Defines the runtime connection parameters used to build WebDAV clients.
/// Protocol params used by WebDAV
#[derive(Debug, Clone)]
pub struct WebDAVProtocolParams {
/// Base WebDAV endpoint URI.
pub uri: String,
/// Username used for authentication.
pub username: String,
/// Password used for authentication.
pub password: String,
}
impl WebDAVProtocolParams {
/// Stores the shared secret as the active WebDAV password.
pub fn set_default_secret(&mut self, secret: String) {
self.password = secret;
}
/// Returns whether the WebDAV password is currently missing.
pub fn password_missing(&self) -> bool {
self.password.is_empty()
}
+49 -42
View File
@@ -13,11 +13,10 @@ use remotefs_kube::KubeMultiPodFs as KubeFs;
use remotefs_smb::SmbOptions;
#[cfg(smb)]
use remotefs_smb::{SmbCredentials, SmbFs};
#[cfg(windows)]
use remotefs_ssh::LibSsh2Session as SshSession;
#[cfg(unix)]
use remotefs_ssh::LibSshSession as SshSession;
use remotefs_ssh::{ScpFs, SftpFs, SshAgentIdentity, SshConfigParseRule, SshOpts};
use remotefs_ssh::{
NoCheckServerKey, RusshSession as SshSession, ScpFs, SftpFs, SshAgentIdentity,
SshConfigParseRule, SshOpts,
};
use remotefs_webdav::WebDAVFs;
#[cfg(not(smb))]
@@ -44,23 +43,23 @@ impl RemoteFsBuilder {
) -> Result<Box<dyn RemoteFs>, String> {
match (protocol, params) {
(FileTransferProtocol::AwsS3, ProtocolParams::AwsS3(params)) => {
Ok(Box::new(Self::aws_s3_client(params)))
Ok(Box::new(Self::aws_s3_client(params)?))
}
(FileTransferProtocol::Ftp(secure), ProtocolParams::Generic(params)) => {
Ok(Box::new(Self::ftp_client(params, secure)))
}
(FileTransferProtocol::Kube, ProtocolParams::Kube(params)) => {
Ok(Box::new(Self::kube_client(params)))
Ok(Box::new(Self::kube_client(params)?))
}
(FileTransferProtocol::Scp, ProtocolParams::Generic(params)) => {
Ok(Box::new(Self::scp_client(params, config_client)))
Ok(Box::new(Self::scp_client(params, config_client)?))
}
(FileTransferProtocol::Sftp, ProtocolParams::Generic(params)) => {
Ok(Box::new(Self::sftp_client(params, config_client)))
Ok(Box::new(Self::sftp_client(params, config_client)?))
}
#[cfg(smb)]
(FileTransferProtocol::Smb, ProtocolParams::Smb(params)) => {
Ok(Box::new(Self::smb_client(params)))
Ok(Box::new(Self::smb_client(params)?))
}
(FileTransferProtocol::WebDAV, ProtocolParams::WebDAV(params)) => {
Ok(Box::new(Self::webdav_client(params)))
@@ -75,13 +74,13 @@ impl RemoteFsBuilder {
}
/// Build aws s3 client from parameters
fn aws_s3_client(params: AwsS3Params) -> AwsS3Fs {
fn aws_s3_client(params: AwsS3Params) -> Result<AwsS3Fs, String> {
let rt = Arc::new(
tokio::runtime::Builder::new_current_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("Unable to create tokio runtime"),
.map_err(|e| format!("Unable to create tokio runtime: {e}"))?,
);
let mut client =
AwsS3Fs::new(params.bucket_name, &rt).new_path_style(params.new_path_style);
@@ -106,7 +105,7 @@ impl RemoteFsBuilder {
if let Some(session_token) = params.session_token {
client = client.session_token(session_token);
}
client
Ok(client)
}
/// Build ftp client from parameters
@@ -125,19 +124,13 @@ impl RemoteFsBuilder {
}
/// Build kube client
fn kube_client(params: KubeProtocolParams) -> KubeFs {
let rt = Arc::new(
tokio::runtime::Builder::new_current_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("Unable to create tokio runtime"),
);
fn kube_client(params: KubeProtocolParams) -> Result<KubeFs, String> {
let rt = Self::tokio_runtime()?;
let kube_fs = KubeFs::new(&rt);
if let Some(config) = params.config() {
kube_fs.config(config)
Ok(kube_fs.config(config))
} else {
kube_fs
Ok(kube_fs)
}
}
@@ -145,20 +138,24 @@ impl RemoteFsBuilder {
fn scp_client(
params: GenericProtocolParams,
config_client: &ConfigClient,
) -> ScpFs<SshSession> {
Self::build_ssh_opts(params, config_client).into()
) -> Result<ScpFs<SshSession<NoCheckServerKey>>, String> {
let opts = Self::build_ssh_opts(params, config_client);
let rt = Self::tokio_runtime()?;
Ok(ScpFs::russh(opts, rt))
}
/// Build sftp client
fn sftp_client(
params: GenericProtocolParams,
config_client: &ConfigClient,
) -> SftpFs<SshSession> {
Self::build_ssh_opts(params, config_client).into()
) -> Result<SftpFs<SshSession<NoCheckServerKey>>, String> {
let opts = Self::build_ssh_opts(params, config_client);
let rt = Self::tokio_runtime()?;
Ok(SftpFs::russh(opts, rt))
}
#[cfg(smb_unix)]
fn smb_client(params: SmbParams) -> SmbFs {
fn smb_client(params: SmbParams) -> Result<SmbFs, String> {
let mut credentials = SmbCredentials::default()
.server(format!("smb://{}:{}", params.address, params.port))
.share(params.share);
@@ -173,22 +170,20 @@ impl RemoteFsBuilder {
credentials = credentials.workgroup(workgroup);
}
match SmbFs::try_new(
SmbFs::try_new(
credentials,
SmbOptions::default()
.one_share_per_server(true)
.case_sensitive(false),
) {
Ok(fs) => fs,
Err(e) => {
error!("Invalid params for protocol SMB: {e}");
panic!("Invalid params for protocol SMB: {e}")
}
}
)
.map_err(|e| {
error!("Invalid params for protocol SMB: {e}");
format!("Invalid params for protocol SMB: {e}")
})
}
#[cfg(smb_windows)]
fn smb_client(params: SmbParams) -> SmbFs {
fn smb_client(params: SmbParams) -> Result<SmbFs, String> {
let mut credentials = SmbCredentials::new(params.address, params.share);
if let Some(username) = params.username {
@@ -198,7 +193,7 @@ impl RemoteFsBuilder {
credentials = credentials.password(password);
}
SmbFs::new(credentials)
Ok(SmbFs::new(credentials))
}
fn webdav_client(params: WebDAVProtocolParams) -> WebDAVFs {
@@ -236,12 +231,13 @@ impl RemoteFsBuilder {
} else {
//* case 3: use system username; can't be None
debug!("no username was provided, using current username");
opts = opts.username(whoami::username());
if let Ok(username) = whoami::username() {
opts = opts.username(username);
}
}
} else {
//* case 3: use system username; can't be None
} else if let Ok(username) = whoami::username() {
debug!("no username was provided, using current username");
opts = opts.username(whoami::username());
opts = opts.username(username);
}
// For SSH protocols, only set password if explicitly provided and non-empty.
// This allows the SSH library to prioritize key-based and agent authentication.
@@ -263,6 +259,17 @@ impl RemoteFsBuilder {
fn make_ssh_storage(config_client: &ConfigClient) -> SshKeyStorage {
SshKeyStorage::from(config_client)
}
/// Create tokio runtime to run async code for remotefs
fn tokio_runtime() -> Result<Arc<tokio::runtime::Runtime>, String> {
Ok(Arc::new(
tokio::runtime::Builder::new_current_thread()
.worker_threads(1)
.enable_all()
.build()
.map_err(|e| format!("Unable to create tokio runtime: {e}"))?,
))
}
}
#[cfg(test)]
+1 -1
View File
@@ -19,7 +19,6 @@ pub type HostResult<T> = Result<T, HostError>;
/// HostErrorType provides an overview of the specific host error
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum HostErrorType {
#[error("No such file or directory")]
NoSuchFileOrDirectory,
@@ -37,6 +36,7 @@ pub enum HostErrorType {
ExecutionFailed,
#[error("Could not delete file")]
DeleteFailed,
#[cfg(win)]
#[error("Not implemented")]
NotImplemented,
#[error("remote fs error: {0}")]
+5
View File
@@ -1,3 +1,8 @@
//! ## Host Bridge
//!
//! Defines the host abstraction used to expose localhost and bridged remote
//! filesystems through a shared interface.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
+62 -1
View File
@@ -1,3 +1,8 @@
//! ## Localhost Host Bridge
//!
//! Implements the host bridge abstraction directly against the local
//! filesystem.
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
#[cfg(posix)]
@@ -347,6 +352,8 @@ impl HostBridge for Localhost {
}
fn exists(&mut self, path: &Path) -> HostResult<bool> {
let path: PathBuf = self.to_path(path);
Ok(path.exists())
}
@@ -395,7 +402,17 @@ impl HostBridge for Localhost {
fn exec(&mut self, cmd: &str) -> HostResult<String> {
// Make command
let args: Vec<&str> = cmd.split(' ').collect();
let cmd: &str = args.first().unwrap();
let cmd: &str = match args.first() {
Some(cmd) => cmd,
None => {
error!("Empty command provided to exec");
return Err(HostError::new(
HostErrorType::ExecutionFailed,
None,
self.wrkdir.as_path(),
));
}
};
let argv: &[&str] = &args[1..];
info!("Executing command: {} {:?}", cmd, argv);
match std::process::Command::new(cmd).args(argv).output() {
@@ -698,6 +715,22 @@ mod tests {
assert!(host.create_file(file.path(), &Metadata::default()).is_err());
}
#[test]
#[cfg(posix)]
fn should_resolve_relative_exists_and_stat_from_workdir() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
let dir_path: &Path = tmpdir.path();
assert!(make_file_at(dir_path, "nested.txt").is_ok());
let mut host: Localhost = Localhost::new(PathBuf::from(dir_path)).ok().unwrap();
assert!(host.exists(Path::new("nested.txt")).unwrap());
assert!(!host.exists(Path::new("missing.txt")).unwrap());
let entry = host.stat(Path::new("nested.txt")).unwrap();
assert_eq!(entry.path(), &dir_path.join("nested.txt"));
assert!(entry.is_file());
}
#[cfg(posix)]
#[test]
fn test_host_localhost_symlinks() {
@@ -993,6 +1026,14 @@ mod tests {
assert!(host.exec("echo 5").ok().unwrap().as_str().contains("5"));
}
#[test]
fn test_host_exec_empty_command() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
// Execute empty command should return error
assert!(host.exec("").is_err());
}
#[cfg(posix)]
#[test]
fn should_create_symlink() {
@@ -1013,6 +1054,26 @@ mod tests {
);
}
#[cfg(posix)]
#[test]
fn should_stat_relative_symlink_as_symlink() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
let dir_path: &Path = tmpdir.path();
assert!(make_file_at(dir_path, "target.txt").is_ok());
let mut host: Localhost = Localhost::new(PathBuf::from(dir_path)).ok().unwrap();
let target = dir_path.join("target.txt");
assert!(
host.symlink(Path::new("link.txt"), target.as_path())
.is_ok()
);
let entry = host.stat(Path::new("link.txt")).unwrap();
assert_eq!(entry.path(), &dir_path.join("link.txt"));
assert!(entry.is_symlink());
assert_eq!(entry.metadata().symlink.as_ref(), Some(&target));
}
#[test]
fn test_host_fmt_error() {
let err: HostError = HostError::new(
+106 -2
View File
@@ -1,13 +1,19 @@
//! ## Remote Bridged Host
//!
//! Bridges a `RemoteFs` implementation behind the local host interface used by
//! the file transfer activity.
mod temp_mapped_file;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use remotefs::fs::{Metadata, UnixPex};
use remotefs::{File, RemoteError, RemoteErrorType, RemoteFs};
use self::temp_mapped_file::TempMappedFile;
use super::{HostBridge, HostError, HostResult};
use crate::utils::path::normalize;
struct WriteStreamOp {
path: PathBuf,
@@ -124,7 +130,8 @@ impl HostBridge for RemoteBridged {
fn list_dir(&mut self, path: &Path) -> HostResult<Vec<File>> {
debug!("Listing directory {:?}", path);
self.remote.list_dir(path).map_err(HostError::from)
let entries = self.remote.list_dir(path).map_err(HostError::from)?;
Ok(filter_self_refs(path, entries))
}
fn setstat(&mut self, path: &Path, metadata: &Metadata) -> HostResult<()> {
@@ -209,3 +216,100 @@ impl HostBridge for RemoteBridged {
Ok(())
}
}
/// Drop entries that refer to the directory being listed.
///
/// Some non-compliant FTP servers (e.g. LiteSpeed) include a self-reference
/// to the listed directory in the LIST response, which would otherwise appear
/// as a duplicate entry in the explorer.
fn filter_self_refs(path: &Path, entries: Vec<File>) -> Vec<File> {
let normalized = normalize(path);
entries
.into_iter()
.filter(|entry| {
let last = entry.path().components().next_back();
let is_dot_ref = matches!(last, Some(Component::CurDir | Component::ParentDir));
!is_dot_ref && normalize(entry.path()) != normalized
})
.collect()
}
#[cfg(test)]
mod test {
use std::path::PathBuf;
use std::time::SystemTime;
use pretty_assertions::assert_eq;
use remotefs::fs::{FileType, Metadata};
use super::*;
fn file(path: &str, file_type: FileType) -> File {
File {
path: PathBuf::from(path),
metadata: Metadata {
accessed: Some(SystemTime::UNIX_EPOCH),
created: Some(SystemTime::UNIX_EPOCH),
modified: Some(SystemTime::UNIX_EPOCH),
file_type,
gid: None,
mode: None,
size: 0,
symlink: None,
uid: None,
},
}
}
#[test]
fn filter_self_refs_drops_entry_matching_listed_dir() {
let entries = vec![
file("/wp-content/wp-content", FileType::Directory),
file("/wp-content/index.php", FileType::File),
];
let filtered = filter_self_refs(Path::new("/wp-content/wp-content"), entries);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path(), Path::new("/wp-content/index.php"));
}
#[test]
fn filter_self_refs_drops_dot_and_dotdot_entries() {
let entries = vec![
file("/foo/.", FileType::Directory),
file("/foo/..", FileType::Directory),
file("/foo/bar", FileType::File),
];
let filtered = filter_self_refs(Path::new("/foo"), entries);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path(), Path::new("/foo/bar"));
}
#[test]
fn filter_self_refs_normalizes_paths() {
let entries = vec![
file("/foo/./bar", FileType::File),
file("/foo/baz/../", FileType::Directory),
];
let filtered = filter_self_refs(Path::new("/foo"), entries);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path(), Path::new("/foo/./bar"));
}
#[test]
fn filter_self_refs_preserves_unrelated_entries() {
let entries = vec![
file("/home/user/notes.txt", FileType::File),
file("/home/user/photos", FileType::Directory),
];
let filtered = filter_self_refs(Path::new("/home/user"), entries.clone());
assert_eq!(filtered.len(), 2);
}
}
+69 -26
View File
@@ -1,3 +1,8 @@
//! ## Temp Mapped File
//!
//! Provides a temporary local file that mirrors a remote file while keeping a
//! lazily opened read/write handle.
use std::fs::File;
use std::io::{self, Read, Write};
use std::sync::{Arc, Mutex};
@@ -16,27 +21,25 @@ pub struct TempMappedFile {
impl Write for TempMappedFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let rc = self.write_hnd()?;
let mut ref_mut = rc.lock().unwrap();
ref_mut.as_mut().unwrap().write(buf)
let mut handle = self.write_hnd()?;
handle.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
let rc = self.write_hnd()?;
let mut ref_mut = rc.lock().unwrap();
ref_mut.as_mut().unwrap().flush()
let mut handle = self.write_hnd()?;
handle.flush()
}
}
impl Read for TempMappedFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let rc = self.read_hnd()?;
let mut ref_mut = rc.lock().unwrap();
ref_mut.as_mut().unwrap().read(buf)
let mut handle = self.read_hnd()?;
handle.read(buf)
}
}
impl TempMappedFile {
/// Creates an empty temporary file container for a downloaded remote file.
pub fn new() -> HostResult<Self> {
NamedTempFile::new()
.map(|tempfile| TempMappedFile {
@@ -57,7 +60,13 @@ impl TempMappedFile {
/// Must be called
pub fn sync(&mut self) -> HostResult<()> {
{
let mut lock = self.handle.lock().unwrap();
let mut lock = self.lock_handle().map_err(|e| {
HostError::new(
HostErrorType::FileNotAccessible,
Some(e),
self.tempfile.path(),
)
})?;
if let Some(hnd) = lock.take() {
hnd.sync_all().map_err(|e| {
@@ -73,28 +82,62 @@ impl TempMappedFile {
Ok(())
}
fn write_hnd(&mut self) -> io::Result<Arc<Mutex<Option<File>>>> {
{
let mut lock = self.handle.lock().unwrap();
if lock.is_none() {
let hnd = File::create(self.tempfile.path())?;
lock.replace(hnd);
}
fn write_hnd(&mut self) -> io::Result<FileHandle<'_>> {
let mut lock = self.lock_handle()?;
if lock.is_none() {
let hnd = File::create(self.tempfile.path())?;
lock.replace(hnd);
}
Ok(self.handle.clone())
Ok(FileHandle::new(lock))
}
fn read_hnd(&mut self) -> io::Result<Arc<Mutex<Option<File>>>> {
{
let mut lock = self.handle.lock().unwrap();
if lock.is_none() {
let hnd = File::open(self.tempfile.path())?;
lock.replace(hnd);
}
fn read_hnd(&mut self) -> io::Result<FileHandle<'_>> {
let mut lock = self.lock_handle()?;
if lock.is_none() {
let hnd = File::open(self.tempfile.path())?;
lock.replace(hnd);
}
Ok(self.handle.clone())
Ok(FileHandle::new(lock))
}
fn lock_handle(&self) -> io::Result<std::sync::MutexGuard<'_, Option<File>>> {
self.handle
.lock()
.map_err(|_| io::Error::other("temporary file handle lock poisoned"))
}
}
struct FileHandle<'a> {
guard: std::sync::MutexGuard<'a, Option<File>>,
}
impl<'a> FileHandle<'a> {
fn new(guard: std::sync::MutexGuard<'a, Option<File>>) -> Self {
Self { guard }
}
fn file_mut(&mut self) -> io::Result<&mut File> {
self.guard
.as_mut()
.ok_or_else(|| io::Error::other("temporary file handle is not initialized"))
}
}
impl Write for FileHandle<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.file_mut()?.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.file_mut()?.flush()
}
}
impl Read for FileHandle<'_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.file_mut()?.read(buf)
}
}
+5 -4
View File
@@ -1,3 +1,8 @@
//! ## termscp
//!
//! Binary entry point for argument parsing, logging setup, and activity
//! manager startup.
mod activity_manager;
mod cli;
mod config;
@@ -15,11 +20,7 @@ extern crate bitflags;
#[macro_use]
extern crate lazy_regex;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[macro_use]
extern crate magic_crypt;
use std::env;
use std::path::{Path, PathBuf};
+10 -4
View File
@@ -1,3 +1,8 @@
//! ## Import SSH Hosts
//!
//! Imports OpenSSH host entries into termscp bookmarks and optionally registers
//! referenced private keys in the configured SSH key storage.
use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
@@ -52,7 +57,9 @@ pub fn import_ssh_hosts(ssh_config: Option<PathBuf>, keyring: bool) -> Result<()
.flat_map(host_to_params)
.for_each(|(name, params, identity_file_params)| {
debug!("Adding bookmark for host: {name} with params: {params:?}");
bookmarks_client.add_bookmark(name, params, false);
if let Err(err) = bookmarks_client.add_bookmark(name, params, false) {
error!("Could not add imported bookmark: {err}");
}
// add ssh key if any
if let Some(identity_file_params) = identity_file_params {
@@ -255,8 +262,7 @@ mod tests {
struct SshTestConfig {
config: NamedTempFile,
#[allow(dead_code)]
identity_file: NamedTempFile,
_identity_file: NamedTempFile,
}
fn ssh_test_config() -> SshTestConfig {
@@ -320,7 +326,7 @@ Host test3
SshTestConfig {
config: file,
identity_file,
_identity_file: identity_file,
}
}
}
View File
+60 -3
View File
@@ -23,7 +23,9 @@ pub enum UpdateStatus {
/// Info related to a github release
#[derive(Debug)]
pub struct Release {
/// Release version string returned by GitHub.
pub version: String,
/// Release notes body returned by GitHub.
pub body: String,
}
@@ -48,6 +50,7 @@ impl Update {
self
}
/// Installs the latest available release using the configured update options.
pub fn upgrade(self) -> Result<UpdateStatus, UpdateError> {
info!("Updating termscp...");
GithubUpdater::configure()
@@ -132,8 +135,13 @@ impl Update {
/// Check wether new version is higher than new version
fn is_new_version_higher(new_version: &str, current_version: &str) -> bool {
version_compare::compare(new_version, current_version).unwrap_or(version_compare::Cmp::Lt)
== version_compare::Cmp::Gt
match (
semver::Version::parse(new_version),
semver::Version::parse(current_version),
) {
(Ok(new), Ok(current)) => new > current,
_ => false,
}
}
}
impl From<Status> for UpdateStatus {
@@ -230,15 +238,64 @@ mod test {
assert_eq!(release.version.as_str(), "0.7.0");
}
#[test]
fn should_default_release_body_when_missing() {
let release: UpdRelease = UpdRelease {
name: String::from("termscp 0.7.0"),
version: String::from("0.7.0"),
date: String::from("2021-09-12T00:00:00Z"),
body: None,
assets: vec![],
};
let release: Release = Release::from(release);
assert!(release.body.is_empty());
assert_eq!(release.version.as_str(), "0.7.0");
}
#[test]
fn should_tell_that_version_is_higher() {
assert!(Update::is_new_version_higher("0.10.0", "0.9.0"));
assert!(Update::is_new_version_higher("0.20.0", "0.19.0"));
assert!(Update::is_new_version_higher("0.20.0", "0.19.1"));
assert!(Update::is_new_version_higher("1.0.0", "0.19.1"));
assert!(!Update::is_new_version_higher("0.9.0", "0.10.0"));
assert!(!Update::is_new_version_higher("0.9.9", "0.10.1"));
assert!(!Update::is_new_version_higher("0.10.9", "0.11.0"));
}
#[test]
fn should_ignore_release_without_semver() {
let release = Release {
version: String::from("latest"),
body: String::from("notes"),
};
assert!(Update::check_version(release).is_none());
}
#[test]
fn should_ignore_release_when_version_is_not_newer() {
let release = Release {
version: cargo_crate_version!().to_string(),
body: String::from("notes"),
};
assert!(Update::check_version(release).is_none());
}
#[test]
fn should_accept_release_when_version_is_newer() {
let release = Release {
version: String::from("termscp-999.0.0"),
body: String::from("notes"),
};
let release = Update::check_version(release).unwrap();
assert_eq!(release.version.as_str(), "termscp-999.0.0");
assert_eq!(release.body.as_str(), "notes");
}
#[test]
fn test_should_check_whether_github_api_is_reachable() {
assert!(Update::check_github_api_reachable().is_ok());
+252 -139
View File
@@ -2,8 +2,6 @@
//!
//! `bookmarks_client` is the module which provides an API between the Bookmarks module and the system
// Crate
// Ext
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::string::ToString;
@@ -12,11 +10,8 @@ use std::time::SystemTime;
use super::keys::filestorage::FileStorage;
use super::keys::keyringstorage::KeyringStorage;
use super::keys::{KeyStorage, KeyStorageError};
// Local
use crate::config::{
bookmarks::{Bookmark, UserHosts},
serialization::{SerializerError, SerializerErrorKind, deserialize, serialize},
};
use crate::config::bookmarks::{Bookmark, UserHosts};
use crate::config::serialization::{SerializerError, SerializerErrorKind, deserialize, serialize};
use crate::filetransfer::FileTransferParams;
use crate::utils::crypto;
use crate::utils::fmt::fmt_time;
@@ -104,7 +99,7 @@ impl BookmarksClient {
fn keyring(storage_path: &Path, keyring: bool) -> (Box<dyn KeyStorage>, &'static str) {
if keyring && cfg!(feature = "keyring") {
debug!("Setting up KeyStorage");
let username: String = whoami::username();
let username = whoami::username().unwrap_or_default();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
// Check if keyring storage is supported
#[cfg(not(test))]
@@ -192,15 +187,15 @@ impl BookmarksClient {
name: S,
params: FileTransferParams,
save_password: bool,
) {
) -> Result<(), SerializerError> {
let name: String = name.as_ref().to_string();
if name.is_empty() {
error!("Fatal error; bookmark name is empty");
panic!("Bookmark name can't be empty");
error!("Bookmark name is empty; ignoring add_bookmark request");
return Ok(());
}
// Make bookmark
info!("Added bookmark {}", name);
let mut host: Bookmark = self.make_bookmark(params);
let mut host: Bookmark = self.make_bookmark(params)?;
// If not save_password, set secrets to `None`
if !save_password {
host.password = None;
@@ -210,6 +205,7 @@ impl BookmarksClient {
}
}
self.hosts.bookmarks.insert(name, host);
Ok(())
}
/// Delete entry from bookmarks
@@ -231,9 +227,9 @@ impl BookmarksClient {
}
/// Add a new recent to bookmarks
pub fn add_recent(&mut self, params: FileTransferParams) {
pub fn add_recent(&mut self, params: FileTransferParams) -> Result<(), SerializerError> {
// Make bookmark
let mut host: Bookmark = self.make_bookmark(params);
let mut host: Bookmark = self.make_bookmark(params)?;
// Null password for recents
host.password = None;
if let Some(s3) = host.s3.as_mut() {
@@ -245,7 +241,7 @@ impl BookmarksClient {
if *value == host {
debug!("Discarding recent since duplicated ({})", key);
// Don't save duplicates
return;
return Ok(());
}
}
// If hosts size is bigger than self.recents_size; pop last
@@ -270,6 +266,7 @@ impl BookmarksClient {
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
info!("Saved recent host {}", name);
self.hosts.recents.insert(name, host);
Ok(())
}
/// Delete entry from recents
@@ -334,27 +331,29 @@ impl BookmarksClient {
}
/// Make bookmark from credentials
fn make_bookmark(&self, params: FileTransferParams) -> Bookmark {
fn make_bookmark(&self, params: FileTransferParams) -> Result<Bookmark, SerializerError> {
let mut bookmark: Bookmark = Bookmark::from(params);
// Encrypt password
if let Some(pwd) = bookmark.password {
bookmark.password = Some(self.encrypt_str(pwd.as_str()));
bookmark.password = Some(self.encrypt_str(pwd.as_str())?);
}
// Encrypt aws s3 params
if let Some(s3) = bookmark.s3.as_mut() {
if let Some(access_key) = s3.access_key.as_mut() {
*access_key = self.encrypt_str(access_key.as_str());
*access_key = self.encrypt_str(access_key.as_str())?;
}
if let Some(secret_access_key) = s3.secret_access_key.as_mut() {
*secret_access_key = self.encrypt_str(secret_access_key.as_str());
*secret_access_key = self.encrypt_str(secret_access_key.as_str())?;
}
}
bookmark
Ok(bookmark)
}
/// Encrypt provided string using AES-128. Encrypted buffer is then converted to BASE64
fn encrypt_str(&self, txt: &str) -> String {
crypto::aes128_b64_crypt(self.key.as_str(), txt)
fn encrypt_str(&self, txt: &str) -> Result<String, SerializerError> {
crypto::aes128_b64_crypt(self.key.as_str(), txt).map_err(|err| {
SerializerError::new_ex(SerializerErrorKind::Serialization, err.to_string())
})
}
/// Decrypt provided string using AES-128
@@ -408,24 +407,32 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add some bookmarks
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
assert!(
client
.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
)
.is_ok()
);
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
let recent_key: String = String::from(client.iter_recents().next().unwrap());
assert!(client.write_bookmarks().is_ok());
let key: String = client.key.clone();
@@ -456,7 +463,11 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add s3 bookmark
client.add_bookmark("my-bucket", make_s3_ftparams(), true);
assert!(
client
.add_bookmark("my-bucket", make_s3_ftparams(), true)
.is_ok()
);
// Verify bookmark
let bookmark = client.get_bookmark("my-bucket").unwrap();
assert_eq!(bookmark.protocol, FileTransferProtocol::AwsS3);
@@ -476,7 +487,11 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add s3 bookmark
client.add_bookmark("my-bucket", make_s3_ftparams(), false);
assert!(
client
.add_bookmark("my-bucket", make_s3_ftparams(), false)
.is_ok()
);
// Verify bookmark
let bookmark = client.get_bookmark("my-bucket").unwrap();
assert_eq!(bookmark.protocol, FileTransferProtocol::AwsS3);
@@ -497,7 +512,7 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add s3 bookmark
client.add_recent(make_s3_ftparams());
assert!(client.add_recent(make_s3_ftparams()).is_ok());
// Verify bookmark
let bookmark = client.iter_recents().next().unwrap();
let bookmark = client.get_recent(bookmark).unwrap();
@@ -520,27 +535,35 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
assert!(
client
.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
)
.is_ok()
);
client.add_bookmark(
"raspberry2",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword2"),
),
true,
assert!(
client
.add_bookmark(
"raspberry2",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword2"),
),
true,
)
.is_ok()
);
// Iter
assert_eq!(client.iter_bookmarks().count(), 2);
@@ -562,26 +585,30 @@ mod tests {
}
#[test]
#[should_panic]
fn test_system_bookmarks_bad_bookmark_name() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
// Add bookmark with empty name should be silently ignored
assert!(
client
.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
)
.is_ok()
);
// No bookmark should have been added
assert_eq!(client.iter_bookmarks().count(), 0);
}
#[test]
@@ -592,16 +619,20 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
false,
assert!(
client
.add_bookmark(
"raspberry",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
false,
)
.is_ok()
);
let bookmark = ftparams_to_tup(client.get_bookmark(&String::from("raspberry")).unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
@@ -620,13 +651,17 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
// Iter
assert_eq!(client.iter_recents().count(), 1);
let key: String = String::from(client.iter_recents().next().unwrap());
@@ -656,20 +691,28 @@ mod tests {
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
// There should be only one recent
assert_eq!(client.iter_recents().count(), 1);
}
@@ -684,31 +727,43 @@ mod tests {
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 2, true).unwrap();
// Add recent, wait 1 second for each one (cause the name depends on time)
// 1
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.1",
22,
"pi",
Some("mypassword"),
));
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.1",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
sleep(Duration::from_secs(1));
// 2
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.2",
22,
"pi",
Some("mypassword"),
));
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.2",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
sleep(Duration::from_secs(1));
// 3
client.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.3",
22,
"pi",
Some("mypassword"),
));
assert!(
client
.add_recent(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.3",
22,
"pi",
Some("mypassword"),
))
.is_ok()
);
// Limit is 2
assert_eq!(client.iter_recents().count(), 2);
// Check that 192.168.1.1 has been removed
@@ -743,25 +798,30 @@ mod tests {
}
#[test]
#[should_panic]
fn test_system_bookmarks_add_bookmark_empty() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
// Add bookmark
client.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
// Add bookmark with empty name should be silently ignored
assert!(
client
.add_bookmark(
"",
make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
),
true,
)
.is_ok()
);
// No bookmark should have been added
assert_eq!(client.iter_bookmarks().count(), 0);
}
#[test]
@@ -779,6 +839,59 @@ mod tests {
assert!(client.decrypt_str("bidoof").is_err());
}
#[test]
fn should_return_bookmark_when_password_decryption_fails() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
let mut bookmark = Bookmark::from(make_generic_ftparams(
FileTransferProtocol::Sftp,
"192.168.1.31",
22,
"pi",
Some("mypassword"),
));
bookmark.password = Some(String::from("not-valid-base64"));
client
.hosts
.bookmarks
.insert(String::from("raspberry"), bookmark);
let bookmark = ftparams_to_tup(client.get_bookmark("raspberry").unwrap());
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(bookmark.4.as_deref(), Some("not-valid-base64"));
}
#[test]
fn should_return_s3_bookmark_when_secret_decryption_fails() {
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
let mut bookmark = Bookmark::from(make_s3_ftparams());
let s3 = bookmark.s3.as_mut().unwrap();
s3.access_key = Some(String::from("bad-access-key"));
s3.secret_access_key = Some(String::from("bad-secret-key"));
client
.hosts
.bookmarks
.insert(String::from("my-bucket"), bookmark);
let bookmark = client.get_bookmark("my-bucket").unwrap();
let params = bookmark.params.s3_params().unwrap();
assert_eq!(bookmark.protocol, FileTransferProtocol::AwsS3);
assert_eq!(params.bucket_name.as_str(), "omar");
assert_eq!(params.region.as_deref(), Some("eu-west-1"));
assert_eq!(params.access_key.as_deref(), Some("bad-access-key"));
assert_eq!(params.secret_access_key.as_deref(), Some("bad-secret-key"));
}
/// Get paths for configuration and key for bookmarks
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let k: PathBuf = PathBuf::from(dir);
+19 -7
View File
@@ -309,7 +309,7 @@ impl ConfigClient {
None => None,
Some(key_path) => {
// Get host and username
let (host, username): (String, String) = Self::get_ssh_tokens(mkey);
let (host, username) = Self::get_ssh_tokens(mkey)?;
// Return key
Some((host, username, PathBuf::from(key_path)))
}
@@ -389,12 +389,18 @@ impl ConfigClient {
}
/// Get ssh tokens starting from ssh host key
/// Panics if key has invalid syntax
/// Returns: (host, username)
fn get_ssh_tokens(host_key: &str) -> (String, String) {
/// Returns: (host, username) or None if key has invalid syntax
fn get_ssh_tokens(host_key: &str) -> Option<(String, String)> {
let tokens: Vec<&str> = host_key.split('@').collect();
assert!(tokens.len() >= 2);
(String::from(tokens[1]), String::from(tokens[0]))
if tokens.len() >= 2 {
Some((String::from(tokens[1]), String::from(tokens[0])))
} else {
error!(
"Invalid SSH host key format: '{}' (expected 'username@host')",
host_key
);
None
}
}
/// Make serializer error from `std::io::Error`
@@ -711,10 +717,16 @@ mod tests {
);
assert_eq!(
ConfigClient::get_ssh_tokens("pi@192.168.1.31"),
(String::from("192.168.1.31"), String::from("pi"))
Some((String::from("192.168.1.31"), String::from("pi")))
);
}
#[test]
fn test_system_config_get_ssh_tokens_invalid() {
assert!(ConfigClient::get_ssh_tokens("invalid").is_none());
assert!(ConfigClient::get_ssh_tokens("").is_none());
}
#[test]
fn test_system_config_make_io_err() {
let err: SerializerError =
+11 -20
View File
@@ -4,20 +4,21 @@
// Ext
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
#[cfg(not(test))]
static CONF_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::config_dir);
#[cfg(test)]
static CONF_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| Some(std::env::temp_dir()));
#[cfg(not(test))]
static CACHE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(dirs::cache_dir);
#[cfg(test)]
static CACHE_DIR: LazyLock<Option<PathBuf>> = LazyLock::new(|| Some(std::env::temp_dir()));
/// Get termscp config directory path and initialize it.
/// Returns None if it's not possible to initialize it
pub fn init_config_dir() -> Result<Option<PathBuf>, String> {
// Get file
#[cfg(not(test))]
lazy_static! {
static ref CONF_DIR: Option<PathBuf> = dirs::config_dir();
}
#[cfg(test)]
lazy_static! {
static ref CONF_DIR: Option<PathBuf> = Some(std::env::temp_dir());
}
if let Some(dir) = CONF_DIR.as_deref() {
init_dir(dir).map(Option::Some)
} else {
@@ -28,16 +29,6 @@ pub fn init_config_dir() -> Result<Option<PathBuf>, String> {
/// Get termscp cache directory path and initialize it.
/// Returns None if it's not possible to initialize it
pub fn init_cache_dir() -> Result<Option<PathBuf>, String> {
// Get file
#[cfg(not(test))]
lazy_static! {
static ref CACHE_DIR: Option<PathBuf> = dirs::cache_dir();
}
#[cfg(test)]
lazy_static! {
static ref CACHE_DIR: Option<PathBuf> = Some(std::env::temp_dir());
}
if let Some(dir) = CACHE_DIR.as_deref() {
init_dir(dir).map(Option::Some)
} else {
@@ -13,7 +13,7 @@ use thiserror::Error;
#[derive(Debug, Error)]
pub enum KeyStorageError {
#[error("Key has a bad syntax")]
BadSytax,
BadSyntax,
#[error("Provider service error")]
ProviderError,
#[error("No such key")]
@@ -54,7 +54,7 @@ mod tests {
#[test]
fn test_system_keys_mod_errors() {
assert_eq!(
KeyStorageError::BadSytax.to_string(),
KeyStorageError::BadSyntax.to_string(),
String::from("Key has a bad syntax")
);
assert_eq!(
+4 -1
View File
@@ -71,7 +71,10 @@ impl KeyStorage for FileStorage {
return Err(KeyStorageError::ProviderError);
}
// Set file to readonly
let mut permissions: Permissions = file.metadata().unwrap().permissions();
let mut permissions: Permissions = file
.metadata()
.map_err(|_| KeyStorageError::ProviderError)?
.permissions();
permissions.set_readonly(true);
let _ = file.set_permissions(permissions);
Ok(())
+4 -5
View File
@@ -37,7 +37,7 @@ impl KeyStorage for KeyringStorage {
| KeyringError::Invalid(_, _)
| KeyringError::Ambiguous(_) => Err(KeyStorageError::ProviderError),
KeyringError::BadEncoding(_) | KeyringError::TooLong(_, _) => {
Err(KeyStorageError::BadSytax)
Err(KeyStorageError::BadSyntax)
}
_ => Err(KeyStorageError::ProviderError),
},
@@ -78,14 +78,13 @@ impl KeyStorage for KeyringStorage {
mod tests {
#[test]
#[cfg(all(not(feature = "github-actions"), not(feature = "isolated-tests")))]
fn test_system_keys_keyringstorage() {
fn test_system_keys_keyring_storage() {
use pretty_assertions::assert_eq;
use whoami::username;
use super::*;
let username: String = username();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
let username = whoami::username().expect("no username");
let storage = KeyringStorage::new(username.as_str());
assert!(storage.is_supported());
let app_name: &str = "termscp-test2";
let secret: &str = "Th15-15/My-Супер-Секрет";
+40 -1
View File
@@ -13,6 +13,8 @@ use ssh2_config::SshConfig;
use super::config_client::ConfigClient;
use crate::utils::ssh as ssh_utils;
/// Resolves SSH identity files from termscp config, SSH config, and standard
/// OpenSSH default locations.
#[derive(Default)]
pub struct SshKeyStorage {
/// Association between {user}@{host} and RSA key path
@@ -38,7 +40,7 @@ impl SshKeyStorage {
/// Resolve host via termscp ssh keys storage
fn resolve_host_in_termscp_storage(&self, host: &str, username: &str) -> Option<&Path> {
let key: String = Self::make_mapkey(host, username);
self.hosts.get(&key).map(|x| x.as_path())
self.hosts.get(&key).map(PathBuf::as_path)
}
/// Resolve host via ssh2 configuration
@@ -206,6 +208,43 @@ Host test
);
}
#[test]
fn should_make_mapkey_from_username_and_host() {
assert_eq!(
SshKeyStorage::make_mapkey("example.org", "veeso"),
"veeso@example.org"
);
}
#[test]
fn should_prefer_termscp_key_over_ssh_config() {
let rsa_key = test_helpers::create_sample_file_with_content(
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDErJhQxEI0+VvhlXVUyh+vMCm7aXfCA/g633AG8ezD/5EylwchtAr2JCoBWnxn4zV8nI9dMqOgm0jO4IsXpKOjQojv+0VOH7I+cDlBg0tk4hFlvyyS6YviDAfDDln3jYUM+5QNDfQLaZlH2WvcJ3mkDxLVlI9MBX1BAeSmChLxwAvxALp2ncImNQLzDO9eHcig3dtMrEKkzXQowRW5Y7eUzg2+vvVq4H2DOjWwUndvB5sJkhEfTUVE7ID8ZdGJo60kUb/02dZYj+IbkAnMCsqktk0cg/4XFX82hEfRYFeb1arkysFisPU1DOb6QielL/axeTebVplaouYcXY0pFdJt root@8c50fd4c345a",
);
let ssh_config_file = test_helpers::create_sample_file_with_content(format!(
r#"
Host test
HostName 127.0.0.1
User test
IdentityFile {}
"#,
rsa_key.path().display()
));
let tmp_dir: tempfile::TempDir = tempfile::TempDir::new().ok().unwrap();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
client.set_ssh_config(Some(ssh_config_file.path().to_string_lossy().to_string()));
assert!(client.add_ssh_key("test", "pi", "stored-key").is_ok());
let storage: SshKeyStorage = SshKeyStorage::from(&client);
let resolved = storage.resolve("test", "pi").unwrap();
assert!(resolved.ends_with("pi@test.key"));
assert_ne!(resolved.as_path(), rsa_key.path());
}
/// Get paths for configuration and keys directory
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let mut k: PathBuf = PathBuf::from(dir);
+2 -2
View File
@@ -23,7 +23,7 @@ impl ThemeProvider {
pub fn new(theme_path: &Path) -> Result<Self, SerializerError> {
let default_theme: Theme = Theme::default();
info!(
"Setting up theme provider with thene path {} ",
"Setting up theme provider with theme path {}",
theme_path.display(),
);
// Create provider
@@ -42,7 +42,7 @@ impl ThemeProvider {
} else {
// otherwise Load configuration from file
if let Err(err) = provider.load() {
error!("Couldn't read thene file: {}", err);
error!("Couldn't read theme file: {}", err);
return Err(err);
}
debug!("Read theme file");
@@ -27,6 +27,8 @@ pub enum FsWatcherError {
PathNotWatched,
#[error("unable to watch path, since it's already watched")]
PathAlreadyWatched,
#[error("watcher event channel disconnected")]
Disconnected,
#[error("unknown event: {0}")]
UnknownEvent(&'static str),
#[error("worker error: {0}")]
@@ -114,7 +116,7 @@ impl FsWatcher {
let res = match self.receiver.recv_timeout(Duration::from_millis(1)) {
Ok(res) => res,
Err(RecvTimeoutError::Timeout) => return Ok(None),
Err(RecvTimeoutError::Disconnected) => panic!("File watcher died"),
Err(RecvTimeoutError::Disconnected) => return Err(FsWatcherError::Disconnected),
};
// convert event to FsChange
@@ -158,7 +160,7 @@ impl FsWatcher {
/// Returns the list of watched paths
pub fn watched_paths(&self) -> Vec<&Path> {
Vec::from_iter(self.paths.keys().map(|x| x.as_path()))
Vec::from_iter(self.paths.keys().map(PathBuf::as_path))
}
/// Unwatch provided path.
View File
@@ -13,9 +13,10 @@ mod view;
// Includes
use std::time::Duration;
use tuirealm::application::PollStrategy;
use tuirealm::application::{Application, PollStrategy};
use tuirealm::event::NoUserEvent;
use tuirealm::listener::EventListenerCfg;
use tuirealm::{Application, NoUserEvent, Update};
use tuirealm::terminal::TerminalAdapter;
use super::{Activity, CROSSTERM_MAX_POLL, Context, ExitReason};
use crate::config::themes::Theme;
@@ -254,9 +255,7 @@ impl AuthActivity {
pub fn new(ticks: Duration) -> AuthActivity {
AuthActivity {
app: Application::init(
EventListenerCfg::default()
.crossterm_input_listener(ticks, CROSSTERM_MAX_POLL)
.poll_timeout(ticks),
EventListenerCfg::default().crossterm_input_listener(ticks, CROSSTERM_MAX_POLL),
),
context: None,
bookmarks_list: Vec::new(),
@@ -369,7 +368,10 @@ impl Activity for AuthActivity {
return;
}
// Tick
match self.app.tick(PollStrategy::UpTo(3)) {
match self
.app
.tick(PollStrategy::UpTo(3, std::time::Duration::from_millis(10)))
{
Ok(messages) => {
for msg in messages.into_iter() {
let mut msg = Some(msg);
+8 -2
View File
@@ -71,7 +71,10 @@ impl AuthActivity {
};
if let Some(bookmarks_cli) = self.bookmarks_client_mut() {
bookmarks_cli.add_bookmark(name.clone(), params, save_password);
if let Err(err) = bookmarks_cli.add_bookmark(name.clone(), params, save_password) {
self.mount_error(format!("Could not save bookmark: {err}"));
return;
}
// Save bookmarks
self.write_bookmarks();
// Remove `name` from bookmarks if exists
@@ -121,7 +124,10 @@ impl AuthActivity {
}
};
if let Some(bookmarks_cli) = self.bookmarks_client_mut() {
bookmarks_cli.add_recent(params);
if let Err(err) = bookmarks_cli.add_recent(params) {
self.mount_error(format!("Could not save recent host: {err}"));
return;
}
// Save bookmarks
self.write_bookmarks();
}
@@ -28,19 +28,19 @@ pub use popup::{
WindowSizeError,
};
pub use text::{HelpFooter, NewVersionDisclaimer, Subtitle, Title};
use tui_realm_stdlib::Phantom;
use tui_realm_stdlib::components::Phantom;
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers, NoUserEvent};
use tuirealm::{Component, MockComponent};
// -- global listener
#[derive(Default, MockComponent)]
#[derive(Default, Component)]
pub struct GlobalListener {
component: Phantom,
}
impl Component<Msg, NoUserEvent> for GlobalListener {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for GlobalListener {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Esc | Key::Function(10),
+63 -42
View File
@@ -2,18 +2,22 @@
//!
//! auth activity bookmarks components
use tui_realm_stdlib::{Input, List, Radio};
use tui_realm_stdlib::components::{Input, List, Radio};
use tuirealm::command::{Cmd, CmdResult, Direction, Position};
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
use tuirealm::props::{Alignment, BorderSides, BorderType, Borders, Color, InputType, TextSpan};
use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue};
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers, NoUserEvent};
use tuirealm::props::{
BorderSides, BorderType, Borders, Color, HorizontalAlignment, InputType, SpanStatic, Style,
TextModifiers, Title,
};
use tuirealm::state::{State, StateValue};
use super::{FormMsg, Msg, UiMsg};
use crate::ui::activities::auth::FormTab;
// -- bookmark list
#[derive(MockComponent)]
#[derive(Component)]
pub struct BookmarksList {
component: List,
}
@@ -23,23 +27,23 @@ impl BookmarksList {
Self {
component: List::default()
.borders(Borders::default().color(color).modifiers(BorderType::Plain))
.highlighted_color(color)
.highlight_style(Style::default().fg(color))
.rewind(true)
.scroll(true)
.step(4)
.title("Bookmarks", Alignment::Left)
.title(Title::from("Bookmarks").alignment(HorizontalAlignment::Left))
.rows(
bookmarks
.iter()
.map(|x| vec![TextSpan::from(x.as_str())])
.collect(),
.map(|x| vec![SpanStatic::from(x.clone())])
.collect::<Vec<Vec<SpanStatic>>>(),
),
}
}
}
impl Component<Msg, NoUserEvent> for BookmarksList {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for BookmarksList {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Down, ..
@@ -77,7 +81,7 @@ impl Component<Msg, NoUserEvent> for BookmarksList {
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => match self.state() {
State::One(StateValue::Usize(choice)) => {
State::Single(StateValue::Usize(choice)) => {
Some(Msg::Form(FormMsg::LoadBookmark(choice)))
}
_ => Some(Msg::None),
@@ -98,7 +102,7 @@ impl Component<Msg, NoUserEvent> for BookmarksList {
// -- recents list
#[derive(MockComponent)]
#[derive(Component)]
pub struct RecentsList {
component: List,
}
@@ -108,23 +112,23 @@ impl RecentsList {
Self {
component: List::default()
.borders(Borders::default().color(color).modifiers(BorderType::Plain))
.highlighted_color(color)
.highlight_style(Style::default().fg(color))
.rewind(true)
.scroll(true)
.step(4)
.title("Recent connections", Alignment::Left)
.title(Title::from("Recent connections").alignment(HorizontalAlignment::Left))
.rows(
bookmarks
.iter()
.map(|x| vec![TextSpan::from(x.as_str())])
.collect(),
.map(|x| vec![SpanStatic::from(x.clone())])
.collect::<Vec<Vec<SpanStatic>>>(),
),
}
}
}
impl Component<Msg, NoUserEvent> for RecentsList {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for RecentsList {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Down, ..
@@ -162,7 +166,7 @@ impl Component<Msg, NoUserEvent> for RecentsList {
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => match self.state() {
State::One(StateValue::Usize(choice)) => {
State::Single(StateValue::Usize(choice)) => {
Some(Msg::Form(FormMsg::LoadRecent(choice)))
}
_ => Some(Msg::None),
@@ -183,7 +187,7 @@ impl Component<Msg, NoUserEvent> for RecentsList {
// -- delete bookmark
#[derive(MockComponent)]
#[derive(Component)]
pub struct DeleteBookmarkPopup {
component: Radio,
}
@@ -192,6 +196,11 @@ impl DeleteBookmarkPopup {
pub fn new(color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
@@ -200,14 +209,15 @@ impl DeleteBookmarkPopup {
.choices(["Yes", "No"])
.value(1)
.rewind(true)
.foreground(color)
.title("Delete selected bookmark?", Alignment::Center),
.title(
Title::from("Delete selected bookmark?").alignment(HorizontalAlignment::Center),
),
}
}
}
impl Component<Msg, NoUserEvent> for DeleteBookmarkPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for DeleteBookmarkPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseDeleteBookmark))
@@ -237,7 +247,7 @@ impl Component<Msg, NoUserEvent> for DeleteBookmarkPopup {
}) => {
if matches!(
self.perform(Cmd::Submit),
CmdResult::Submit(State::One(StateValue::Usize(0)))
CmdResult::Submit(State::Single(StateValue::Usize(0)))
) {
Some(Msg::Form(FormMsg::DeleteBookmark))
} else {
@@ -251,7 +261,7 @@ impl Component<Msg, NoUserEvent> for DeleteBookmarkPopup {
// -- delete recent
#[derive(MockComponent)]
#[derive(Component)]
pub struct DeleteRecentPopup {
component: Radio,
}
@@ -260,6 +270,11 @@ impl DeleteRecentPopup {
pub fn new(color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
@@ -268,14 +283,16 @@ impl DeleteRecentPopup {
.choices(["Yes", "No"])
.value(1)
.rewind(true)
.foreground(color)
.title("Delete selected recent host?", Alignment::Center),
.title(
Title::from("Delete selected recent host?")
.alignment(HorizontalAlignment::Center),
),
}
}
}
impl Component<Msg, NoUserEvent> for DeleteRecentPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for DeleteRecentPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseDeleteRecent))
@@ -305,7 +322,7 @@ impl Component<Msg, NoUserEvent> for DeleteRecentPopup {
}) => {
if matches!(
self.perform(Cmd::Submit),
CmdResult::Submit(State::One(StateValue::Usize(0)))
CmdResult::Submit(State::Single(StateValue::Usize(0)))
) {
Some(Msg::Form(FormMsg::DeleteRecent))
} else {
@@ -321,7 +338,7 @@ impl Component<Msg, NoUserEvent> for DeleteRecentPopup {
// -- save password
#[derive(MockComponent)]
#[derive(Component)]
pub struct BookmarkSavePassword {
component: Radio,
form_tab: FormTab,
@@ -331,6 +348,11 @@ impl BookmarkSavePassword {
pub fn new(form_tab: FormTab, color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(Color::Reset)
@@ -340,15 +362,14 @@ impl BookmarkSavePassword {
.choices(["Yes", "No"])
.value(0)
.rewind(true)
.foreground(color)
.title("Save secrets?", Alignment::Center),
.title(Title::from("Save secrets?").alignment(HorizontalAlignment::Center)),
form_tab,
}
}
}
impl Component<Msg, NoUserEvent> for BookmarkSavePassword {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for BookmarkSavePassword {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseSaveBookmark))
@@ -378,7 +399,7 @@ impl Component<Msg, NoUserEvent> for BookmarkSavePassword {
// -- new bookmark name
#[derive(MockComponent)]
#[derive(Component)]
pub struct BookmarkName {
component: Input,
form_tab: FormTab,
@@ -395,15 +416,15 @@ impl BookmarkName {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title("Bookmark name", Alignment::Left)
.title(Title::from("Bookmark name").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text),
form_tab,
}
}
}
impl Component<Msg, NoUserEvent> for BookmarkName {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for BookmarkName {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseSaveBookmark))
@@ -447,7 +468,7 @@ impl Component<Msg, NoUserEvent> for BookmarkName {
code: Key::Char(ch),
..
}) => {
self.perform(Cmd::Type(ch));
self.perform(Cmd::Type(*ch));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
use super::*;
#[derive(Component)]
pub struct InputAddress {
component: Input,
form_tab: FormTab,
}
impl InputAddress {
pub fn new(host: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"127.0.0.1",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Remote host").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(host),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputAddress {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::AddressBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::AddressBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::AddressBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::AddressBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputPort {
component: Input,
form_tab: FormTab,
}
impl InputPort {
pub fn new(port: u16, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"22",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.input_type(InputType::UnsignedInteger)
.input_len(5)
.title(Title::from("Port number").alignment(HorizontalAlignment::Left))
.value(port.to_string()),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputPort {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::PortBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::PortBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::PortBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::PortBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputUsername {
component: Input,
form_tab: FormTab,
}
impl InputUsername {
pub fn new(username: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"root",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Username").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(username),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputUsername {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::UsernameBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::UsernameBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::UsernameBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::UsernameBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputPassword {
component: Input,
form_tab: FormTab,
}
impl InputPassword {
pub fn new(password: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Password").alignment(HorizontalAlignment::Left))
.input_type(InputType::Password('*'))
.value(password),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputPassword {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::PasswordBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::PasswordBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::PasswordBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::PasswordBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
@@ -0,0 +1,236 @@
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
use super::*;
#[derive(Component)]
pub struct InputKubeNamespace {
component: Input,
form_tab: FormTab,
}
impl InputKubeNamespace {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"namespace",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Pod namespace (optional)").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputKubeNamespace {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeNamespaceBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeNamespaceBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeNamespaceBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeNamespaceBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputKubeClusterUrl {
component: Input,
form_tab: FormTab,
}
impl InputKubeClusterUrl {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"cluster url",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(
Title::from("Kube cluster url (optional)").alignment(HorizontalAlignment::Left),
)
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputKubeClusterUrl {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClusterUrlBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClusterUrlBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClusterUrlBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClusterUrlBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputKubeUsername {
component: Input,
form_tab: FormTab,
}
impl InputKubeUsername {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"username",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Kube username (optional)").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputKubeUsername {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeUsernameBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeUsernameBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeUsernameBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeUsernameBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputKubeClientCert {
component: Input,
form_tab: FormTab,
}
impl InputKubeClientCert {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"/home/user/.kube/client.crt",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(
Title::from("Kube client cert path (optional)")
.alignment(HorizontalAlignment::Left),
)
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputKubeClientCert {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClientCertBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClientCertBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClientCertBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClientCertBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputKubeClientKey {
component: Input,
form_tab: FormTab,
}
impl InputKubeClientKey {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"/home/user/.kube/client.key",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(
Title::from("Kube client key path (optional)")
.alignment(HorizontalAlignment::Left),
)
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputKubeClientKey {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClientKeyBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClientKeyBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::KubeClientKeyBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::KubeClientKeyBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
@@ -0,0 +1,102 @@
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
use super::*;
#[derive(Component)]
pub struct InputRemoteDirectory {
component: Input,
form_tab: FormTab,
}
impl InputRemoteDirectory {
pub fn new(remote_dir: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"/home/foo",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(
Title::from("Default remote working directory")
.alignment(HorizontalAlignment::Left),
)
.input_type(InputType::Text)
.value(remote_dir),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputRemoteDirectory {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::RemoteDirectoryBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::RemoteDirectoryBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::RemoteDirectoryBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::RemoteDirectoryBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputLocalDirectory {
component: Input,
form_tab: FormTab,
}
impl InputLocalDirectory {
pub fn new(local_dir: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"/home/foo",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(
Title::from("Default local working directory")
.alignment(HorizontalAlignment::Left),
)
.input_type(InputType::Text)
.value(local_dir),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputLocalDirectory {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::LocalDirectoryBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::LocalDirectoryBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::LocalDirectoryBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::LocalDirectoryBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
@@ -0,0 +1,243 @@
use tuirealm::command::{Cmd, CmdResult, Direction};
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, NoUserEvent};
use tuirealm::props::{
BorderType, Borders, Color, HorizontalAlignment, Style, TextModifiers, Title,
};
use tuirealm::state::{State, StateValue};
use super::*;
#[derive(Component)]
pub struct RemoteProtocolRadio {
component: Radio,
}
impl RemoteProtocolRadio {
pub fn new(default_protocol: FileTransferProtocol, color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.choices(if cfg!(smb) {
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"].into_iter()
} else {
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"].into_iter()
})
.rewind(true)
.title(Title::from("Protocol").alignment(HorizontalAlignment::Left))
.value(Self::protocol_enum_to_opt(default_protocol)),
}
}
fn protocol_opt_to_enum(protocol: usize) -> FileTransferProtocol {
match protocol {
REMOTE_RADIO_PROTOCOL_SCP => FileTransferProtocol::Scp,
REMOTE_RADIO_PROTOCOL_FTP => FileTransferProtocol::Ftp(false),
REMOTE_RADIO_PROTOCOL_FTPS => FileTransferProtocol::Ftp(true),
REMOTE_RADIO_PROTOCOL_S3 => FileTransferProtocol::AwsS3,
REMOTE_RADIO_PROTOCOL_SMB => FileTransferProtocol::Smb,
REMOTE_RADIO_PROTOCOL_KUBE => FileTransferProtocol::Kube,
REMOTE_RADIO_PROTOCOL_WEBDAV => FileTransferProtocol::WebDAV,
_ => FileTransferProtocol::Sftp,
}
}
fn protocol_enum_to_opt(protocol: FileTransferProtocol) -> usize {
match protocol {
FileTransferProtocol::Sftp => REMOTE_RADIO_PROTOCOL_SFTP,
FileTransferProtocol::Scp => REMOTE_RADIO_PROTOCOL_SCP,
FileTransferProtocol::Ftp(false) => REMOTE_RADIO_PROTOCOL_FTP,
FileTransferProtocol::Ftp(true) => REMOTE_RADIO_PROTOCOL_FTPS,
FileTransferProtocol::AwsS3 => REMOTE_RADIO_PROTOCOL_S3,
FileTransferProtocol::Kube => REMOTE_RADIO_PROTOCOL_KUBE,
FileTransferProtocol::Smb => REMOTE_RADIO_PROTOCOL_SMB,
FileTransferProtocol::WebDAV => REMOTE_RADIO_PROTOCOL_WEBDAV,
}
}
}
impl AppComponent<Msg, NoUserEvent> for RemoteProtocolRadio {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let result = match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => self.perform(Cmd::Move(Direction::Left)),
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => self.perform(Cmd::Move(Direction::Right)),
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => return Some(Msg::Form(FormMsg::Connect)),
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => return Some(Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ProtocolBlurDown))),
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
return Some(Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ProtocolBlurUp)));
}
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
return Some(Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ParamsFormBlur)));
}
Event::Keyboard(KeyEvent {
code: Key::BackTab, ..
}) => return Some(Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ChangeFormTab))),
_ => return None,
};
match result {
CmdResult::Changed(State::Single(StateValue::Usize(choice))) => Some(Msg::Form(
FormMsg::RemoteProtocolChanged(Self::protocol_opt_to_enum(choice)),
)),
_ => Some(Msg::None),
}
}
}
#[derive(Component)]
pub struct HostBridgeProtocolRadio {
component: Radio,
}
impl HostBridgeProtocolRadio {
pub fn new(protocol: HostBridgeProtocol, color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.choices(if cfg!(smb) {
vec![
"Localhost",
"SFTP",
"SCP",
"FTP",
"FTPS",
"S3",
"Kube",
"WebDAV",
"SMB",
]
.into_iter()
} else {
vec![
"Localhost",
"SFTP",
"SCP",
"FTP",
"FTPS",
"S3",
"Kube",
"WebDAV",
]
.into_iter()
})
.rewind(true)
.title(Title::from("Host type").alignment(HorizontalAlignment::Left))
.value(Self::protocol_to_opt(protocol)),
}
}
fn protocol_to_opt(protocol: HostBridgeProtocol) -> usize {
match protocol {
HostBridgeProtocol::Localhost => HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST,
HostBridgeProtocol::Remote(FileTransferProtocol::Sftp) => {
HOST_BRIDGE_RADIO_PROTOCOL_SFTP
}
HostBridgeProtocol::Remote(FileTransferProtocol::Scp) => HOST_BRIDGE_RADIO_PROTOCOL_SCP,
HostBridgeProtocol::Remote(FileTransferProtocol::Ftp(false)) => {
HOST_BRIDGE_RADIO_PROTOCOL_FTP
}
HostBridgeProtocol::Remote(FileTransferProtocol::Ftp(true)) => {
HOST_BRIDGE_RADIO_PROTOCOL_FTPS
}
HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3) => {
HOST_BRIDGE_RADIO_PROTOCOL_S3
}
HostBridgeProtocol::Remote(FileTransferProtocol::Smb) => HOST_BRIDGE_RADIO_PROTOCOL_SMB,
HostBridgeProtocol::Remote(FileTransferProtocol::Kube) => {
HOST_BRIDGE_RADIO_PROTOCOL_KUBE
}
HostBridgeProtocol::Remote(FileTransferProtocol::WebDAV) => {
HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV
}
}
}
fn protocol_opt_to_enum(protocol: usize) -> HostBridgeProtocol {
match protocol {
HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST => HostBridgeProtocol::Localhost,
HOST_BRIDGE_RADIO_PROTOCOL_SFTP => {
HostBridgeProtocol::Remote(FileTransferProtocol::Sftp)
}
HOST_BRIDGE_RADIO_PROTOCOL_SCP => HostBridgeProtocol::Remote(FileTransferProtocol::Scp),
HOST_BRIDGE_RADIO_PROTOCOL_FTP => {
HostBridgeProtocol::Remote(FileTransferProtocol::Ftp(false))
}
HOST_BRIDGE_RADIO_PROTOCOL_FTPS => {
HostBridgeProtocol::Remote(FileTransferProtocol::Ftp(true))
}
HOST_BRIDGE_RADIO_PROTOCOL_S3 => {
HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3)
}
HOST_BRIDGE_RADIO_PROTOCOL_SMB => HostBridgeProtocol::Remote(FileTransferProtocol::Smb),
HOST_BRIDGE_RADIO_PROTOCOL_KUBE => {
HostBridgeProtocol::Remote(FileTransferProtocol::Kube)
}
HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV => {
HostBridgeProtocol::Remote(FileTransferProtocol::WebDAV)
}
_ => HostBridgeProtocol::Localhost,
}
}
}
impl AppComponent<Msg, NoUserEvent> for HostBridgeProtocolRadio {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let result = match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => self.perform(Cmd::Move(Direction::Left)),
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => self.perform(Cmd::Move(Direction::Right)),
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => return Some(Msg::Form(FormMsg::Connect)),
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => return Some(Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ProtocolBlurDown))),
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
return Some(Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ProtocolBlurUp)));
}
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
return Some(Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ParamsFormBlur)));
}
Event::Keyboard(KeyEvent {
code: Key::BackTab, ..
}) => return Some(Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ChangeFormTab))),
_ => return None,
};
match result {
CmdResult::Changed(State::Single(StateValue::Usize(choice))) => Some(Msg::Form(
FormMsg::HostBridgeProtocolChanged(Self::protocol_opt_to_enum(choice)),
)),
_ => Some(Msg::None),
}
}
}
@@ -0,0 +1,426 @@
use tuirealm::command::{Cmd, Direction};
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, NoUserEvent};
use super::*;
#[derive(Component)]
pub struct InputS3Bucket {
component: Input,
form_tab: FormTab,
}
impl InputS3Bucket {
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"my-bucket",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Bucket name").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(bucket),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3Bucket {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3BucketBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3BucketBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3BucketBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3BucketBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3Region {
component: Input,
form_tab: FormTab,
}
impl InputS3Region {
pub fn new(region: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"eu-west-1",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Region").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(region),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3Region {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3RegionBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3RegionBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3RegionBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3RegionBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3Endpoint {
component: Input,
form_tab: FormTab,
}
impl InputS3Endpoint {
pub fn new(endpoint: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"http://localhost:9000",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Endpoint").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(endpoint),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3Endpoint {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3EndpointBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3EndpointBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3EndpointBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3EndpointBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct RadioS3NewPathStyle {
component: Radio,
form_tab: FormTab,
}
impl RadioS3NewPathStyle {
pub fn new(new_path_style: bool, form_tab: FormTab, color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.choices(["Yes", "No"])
.rewind(true)
.title(Title::from("New path style").alignment(HorizontalAlignment::Left))
.value(usize::from(!new_path_style)),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for RadioS3NewPathStyle {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => {
self.perform(Cmd::Move(Direction::Left));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => {
self.perform(Cmd::Move(Direction::Right));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => Some(Msg::Form(FormMsg::Connect)),
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3NewPathStyleBlurDown))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3NewPathStyleBlurDown))
}),
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3NewPathStyleBlurUp))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3NewPathStyleBlurUp))
})
}
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
Some(if self.form_tab == FormTab::Remote {
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ParamsFormBlur))
} else {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ParamsFormBlur))
})
}
_ => None,
}
}
}
#[derive(Component)]
pub struct InputS3Profile {
component: Input,
form_tab: FormTab,
}
impl InputS3Profile {
pub fn new(profile: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"default",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Profile").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(profile),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3Profile {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3ProfileBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3ProfileBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3ProfileBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3ProfileBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3AccessKey {
component: Input,
form_tab: FormTab,
}
impl InputS3AccessKey {
pub fn new(access_key: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"AKIA...",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("Access key").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(access_key),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3AccessKey {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3AccessKeyBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3AccessKeyBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3AccessKeyBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3AccessKeyBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3SecretAccessKey {
component: Input,
form_tab: FormTab,
}
impl InputS3SecretAccessKey {
pub fn new(secret_access_key: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Secret access key").alignment(HorizontalAlignment::Left))
.input_type(InputType::Password('*'))
.value(secret_access_key),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3SecretAccessKey {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SecretAccessKeyBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SecretAccessKeyBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SecretAccessKeyBlurUp)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SecretAccessKeyBlurUp))
}
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3SecurityToken {
component: Input,
form_tab: FormTab,
}
impl InputS3SecurityToken {
pub fn new(security_token: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Security token").alignment(HorizontalAlignment::Left))
.input_type(InputType::Password('*'))
.value(security_token),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3SecurityToken {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SecurityTokenBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SecurityTokenBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SecurityTokenBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SecurityTokenBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[derive(Component)]
pub struct InputS3SessionToken {
component: Input,
form_tab: FormTab,
}
impl InputS3SessionToken {
pub fn new(session_token: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Session token").alignment(HorizontalAlignment::Left))
.input_type(InputType::Password('*'))
.value(session_token),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputS3SessionToken {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SessionTokenBlurDown)),
FormTab::HostBridge => {
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SessionTokenBlurDown))
}
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::S3SessionTokenBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::S3SessionTokenBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
@@ -0,0 +1,87 @@
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
use super::*;
#[derive(Component)]
pub struct InputSmbShare {
component: Input,
form_tab: FormTab,
}
impl InputSmbShare {
pub fn new(host: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Share").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(host),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputSmbShare {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbShareBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbShareBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbShareBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbShareBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
#[cfg(posix)]
#[derive(Component)]
pub struct InputSmbWorkgroup {
component: Input,
form_tab: FormTab,
}
#[cfg(posix)]
impl InputSmbWorkgroup {
pub fn new(host: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title(Title::from("Workgroup").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(host),
form_tab,
}
}
}
#[cfg(posix)]
impl AppComponent<Msg, NoUserEvent> for InputSmbWorkgroup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbWorkgroupDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbWorkgroupDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbWorkgroupUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbWorkgroupUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
@@ -0,0 +1,48 @@
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::NoUserEvent;
use super::*;
#[derive(Component)]
pub struct InputWebDAVUri {
component: Input,
form_tab: FormTab,
}
impl InputWebDAVUri {
pub fn new(host: &str, form_tab: FormTab, color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.placeholder(tuirealm::props::SpanStatic::styled(
"http://localhost:8080",
Style::default().fg(Color::Rgb(128, 128, 128)),
))
.title(Title::from("HTTP url").alignment(HorizontalAlignment::Left))
.input_type(InputType::Text)
.value(host),
form_tab,
}
}
}
impl AppComponent<Msg, NoUserEvent> for InputWebDAVUri {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
let on_key_down = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::WebDAVUriBlurDown)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::WebDAVUriBlurDown)),
};
let on_key_up = match self.form_tab {
FormTab::Remote => Msg::Ui(UiMsg::Remote(UiAuthFormMsg::WebDAVUriBlurUp)),
FormTab::HostBridge => Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::WebDAVUriBlurUp)),
};
let form_tab = self.form_tab;
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
}
}
+92 -68
View File
@@ -2,17 +2,23 @@
//!
//! auth activity popups
use tui_realm_stdlib::{List, Paragraph, Radio, Textarea};
use tui_realm_stdlib::components::{List, Paragraph, Radio, Textarea};
use tuirealm::command::{Cmd, CmdResult, Direction, Position};
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
use tuirealm::props::{Alignment, BorderType, Borders, Color, TableBuilder, TextSpan};
use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue};
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, Key, KeyEvent, KeyModifiers, NoUserEvent};
use tuirealm::props::{
BorderType, Borders, Color, HorizontalAlignment, SpanStatic, Style, TableBuilder,
TextModifiers, Title,
};
use tuirealm::ratatui::style::Stylize;
use tuirealm::ratatui::text::Text;
use tuirealm::state::{State, StateValue};
use super::{FormMsg, Msg, UiMsg};
// -- error popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct ErrorPopup {
component: Paragraph,
}
@@ -21,21 +27,23 @@ impl ErrorPopup {
pub fn new<S: AsRef<str>>(text: S, color: Color) -> Self {
Self {
component: Paragraph::default()
.alignment(Alignment::Center)
.alignment_horizontal(HorizontalAlignment::Center)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text([TextSpan::from(text.as_ref())])
.wrap(true),
.text(Text::from_iter([SpanStatic::from(
text.as_ref().to_string(),
)]))
.wrap_trim(true),
}
}
}
impl Component<Msg, NoUserEvent> for ErrorPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for ErrorPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Esc | Key::Enter,
@@ -48,7 +56,7 @@ impl Component<Msg, NoUserEvent> for ErrorPopup {
// -- info popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct InfoPopup {
component: Paragraph,
}
@@ -57,21 +65,23 @@ impl InfoPopup {
pub fn new<S: AsRef<str>>(text: S, color: Color) -> Self {
Self {
component: Paragraph::default()
.alignment(Alignment::Center)
.alignment_horizontal(HorizontalAlignment::Center)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text([TextSpan::from(text.as_ref())])
.wrap(true),
.text(Text::from_iter([SpanStatic::from(
text.as_ref().to_string(),
)]))
.wrap_trim(true),
}
}
}
impl Component<Msg, NoUserEvent> for InfoPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for InfoPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Esc | Key::Enter,
@@ -84,7 +94,7 @@ impl Component<Msg, NoUserEvent> for InfoPopup {
// -- wait popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct WaitPopup {
component: Paragraph,
}
@@ -93,28 +103,30 @@ impl WaitPopup {
pub fn new<S: AsRef<str>>(text: S, color: Color) -> Self {
Self {
component: Paragraph::default()
.alignment(Alignment::Center)
.alignment_horizontal(HorizontalAlignment::Center)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text([TextSpan::from(text.as_ref())])
.wrap(true),
.text(Text::from_iter([SpanStatic::from(
text.as_ref().to_string(),
)]))
.wrap_trim(true),
}
}
}
impl Component<Msg, NoUserEvent> for WaitPopup {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for WaitPopup {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
// -- window size error
#[derive(MockComponent)]
#[derive(Component)]
pub struct WindowSizeError {
component: Paragraph,
}
@@ -123,30 +135,30 @@ impl WindowSizeError {
pub fn new(color: Color) -> Self {
Self {
component: Paragraph::default()
.alignment(Alignment::Center)
.alignment_horizontal(HorizontalAlignment::Center)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text([TextSpan::from(
.text(Text::from_iter([SpanStatic::from(
"termscp requires at least 24 lines of height to run",
)])
.wrap(true),
)]))
.wrap_trim(true),
}
}
}
impl Component<Msg, NoUserEvent> for WindowSizeError {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for WindowSizeError {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
// -- quit popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct QuitPopup {
component: Radio,
}
@@ -155,21 +167,25 @@ impl QuitPopup {
pub fn new(color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title("Quit termscp?", Alignment::Center)
.title(Title::from("Quit termscp?").alignment(HorizontalAlignment::Center))
.rewind(true)
.choices(["Yes", "No"]),
}
}
}
impl Component<Msg, NoUserEvent> for QuitPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for QuitPopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseQuitPopup))
@@ -199,7 +215,7 @@ impl Component<Msg, NoUserEvent> for QuitPopup {
}) => {
if matches!(
self.perform(Cmd::Submit),
CmdResult::Submit(State::One(StateValue::Usize(0)))
CmdResult::Submit(State::Single(StateValue::Usize(0)))
) {
Some(Msg::Form(FormMsg::Quit))
} else {
@@ -213,7 +229,7 @@ impl Component<Msg, NoUserEvent> for QuitPopup {
// -- install update popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct InstallUpdatePopup {
component: Radio,
}
@@ -222,21 +238,25 @@ impl InstallUpdatePopup {
pub fn new(color: Color) -> Self {
Self {
component: Radio::default()
.highlight_style(
Style::default()
.fg(color)
.add_modifier(TextModifiers::REVERSED),
)
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title("Install update?", Alignment::Center)
.title(Title::from("Install update?").alignment(HorizontalAlignment::Center))
.rewind(true)
.choices(["Yes", "No"]),
}
}
}
impl Component<Msg, NoUserEvent> for InstallUpdatePopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for InstallUpdatePopup {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseInstallUpdatePopup))
@@ -266,7 +286,7 @@ impl Component<Msg, NoUserEvent> for InstallUpdatePopup {
}) => {
if matches!(
self.perform(Cmd::Submit),
CmdResult::Submit(State::One(StateValue::Usize(0)))
CmdResult::Submit(State::Single(StateValue::Usize(0)))
) {
Some(Msg::Form(FormMsg::InstallUpdate))
} else {
@@ -280,7 +300,7 @@ impl Component<Msg, NoUserEvent> for InstallUpdatePopup {
// -- release notes popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct ReleaseNotes {
component: Textarea,
}
@@ -295,14 +315,14 @@ impl ReleaseNotes {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.title("Release notes", Alignment::Center)
.text_rows(notes.lines().map(TextSpan::from)),
.title(Title::from("Release notes").alignment(HorizontalAlignment::Center))
.text_rows(notes.lines().map(|l| SpanStatic::from(l.to_string()))),
}
}
}
impl Component<Msg, NoUserEvent> for ReleaseNotes {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for ReleaseNotes {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Esc | Key::Enter,
@@ -348,7 +368,7 @@ impl Component<Msg, NoUserEvent> for ReleaseNotes {
// -- keybindings popup
#[derive(MockComponent)]
#[derive(Component)]
pub struct Keybindings {
component: List,
}
@@ -362,43 +382,47 @@ impl Keybindings {
.color(color)
.modifiers(BorderType::Rounded),
)
.highlighted_str("? ")
.title("Keybindings", Alignment::Center)
.highlight_str("? ")
.title(Title::from("Keybindings").alignment(HorizontalAlignment::Center))
.scroll(true)
.step(4)
.rows(
TableBuilder::default()
.add_col(TextSpan::new("<ESC>").bold().fg(color))
.add_col(TextSpan::from(" Quit termscp"))
.add_col(SpanStatic::raw("<ESC>").bold().fg(color))
.add_col(SpanStatic::from(" Quit termscp"))
.add_row()
.add_col(TextSpan::new("<TAB>").bold().fg(color))
.add_col(TextSpan::from(" Switch from form and bookmarks"))
.add_col(SpanStatic::raw("<TAB>").bold().fg(color))
.add_col(SpanStatic::from(
" Switch from form and bookmarks",
))
.add_row()
.add_col(TextSpan::new("<RIGHT/LEFT>").bold().fg(color))
.add_col(TextSpan::from(" Switch bookmark tab"))
.add_col(SpanStatic::raw("<RIGHT/LEFT>").bold().fg(color))
.add_col(SpanStatic::from(" Switch bookmark tab"))
.add_row()
.add_col(TextSpan::new("<UP/DOWN>").bold().fg(color))
.add_col(TextSpan::from(" Move up/down in current tab"))
.add_col(SpanStatic::raw("<UP/DOWN>").bold().fg(color))
.add_col(SpanStatic::from(" Move up/down in current tab"))
.add_row()
.add_col(TextSpan::new("<ENTER>").bold().fg(color))
.add_col(TextSpan::from(" Connect/Load bookmark"))
.add_col(SpanStatic::raw("<ENTER>").bold().fg(color))
.add_col(SpanStatic::from(" Connect/Load bookmark"))
.add_row()
.add_col(TextSpan::new("<DEL|E>").bold().fg(color))
.add_col(TextSpan::from(" Delete selected bookmark"))
.add_col(SpanStatic::raw("<DEL|E>").bold().fg(color))
.add_col(SpanStatic::from(" Delete selected bookmark"))
.add_row()
.add_col(TextSpan::new("<CTRL+C>").bold().fg(color))
.add_col(TextSpan::from(" Enter setup"))
.add_col(SpanStatic::raw("<CTRL+C>").bold().fg(color))
.add_col(SpanStatic::from(" Enter setup"))
.add_row()
.add_col(TextSpan::new("<CTRL+S>").bold().fg(color))
.add_col(TextSpan::from(" Save bookmark"))
.build(),
.add_col(SpanStatic::raw("<CTRL+S>").bold().fg(color))
.add_col(SpanStatic::from(" Save bookmark"))
.build()
.into_iter()
.map(|row| row.into_iter().flat_map(|l| l.spans).collect::<Vec<_>>()),
),
}
}
}
impl Component<Msg, NoUserEvent> for Keybindings {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for Keybindings {
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Esc | Key::Enter,
+34 -32
View File
@@ -2,15 +2,17 @@
//!
//! auth activity texts
use tui_realm_stdlib::{Label, Span};
use tuirealm::props::{Color, TextModifiers, TextSpan};
use tuirealm::{Component, Event, MockComponent, NoUserEvent};
use tui_realm_stdlib::components::{Label, Span};
use tuirealm::component::{AppComponent, Component};
use tuirealm::event::{Event, NoUserEvent};
use tuirealm::props::{Color, SpanStatic, TextModifiers};
use tuirealm::ratatui::style::Stylize;
use super::Msg;
// -- Title
#[derive(MockComponent)]
#[derive(Component)]
pub struct Title {
component: Label,
}
@@ -25,15 +27,15 @@ impl Default for Title {
}
}
impl Component<Msg, NoUserEvent> for Title {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for Title {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
// -- subtitle
#[derive(MockComponent)]
#[derive(Component)]
pub struct Subtitle {
component: Label,
}
@@ -48,15 +50,15 @@ impl Default for Subtitle {
}
}
impl Component<Msg, NoUserEvent> for Subtitle {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for Subtitle {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
// -- new version disclaimer
#[derive(MockComponent)]
#[derive(Component)]
pub struct NewVersionDisclaimer {
component: Span,
}
@@ -65,9 +67,9 @@ impl NewVersionDisclaimer {
pub fn new(new_version: &str, color: Color) -> Self {
Self {
component: Span::default().foreground(color).spans([
TextSpan::from("termscp "),
TextSpan::new(new_version).underlined().bold(),
TextSpan::from(
SpanStatic::from("termscp "),
SpanStatic::raw(new_version.to_string()).underlined().bold(),
SpanStatic::from(
" is NOW available! Install update and view release notes with <CTRL+R>",
),
]),
@@ -75,15 +77,15 @@ impl NewVersionDisclaimer {
}
}
impl Component<Msg, NoUserEvent> for NewVersionDisclaimer {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for NewVersionDisclaimer {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
// -- HelpFooter
#[derive(MockComponent)]
#[derive(Component)]
pub struct HelpFooter {
component: Span,
}
@@ -92,27 +94,27 @@ impl HelpFooter {
pub fn new(key_color: Color) -> Self {
Self {
component: Span::default().spans([
TextSpan::from("<F1|CTRL+H>").bold().fg(key_color),
TextSpan::from(" Help "),
TextSpan::from("<CTRL+C>").bold().fg(key_color),
TextSpan::from(" Enter setup "),
TextSpan::from("<UP/DOWN>").bold().fg(key_color),
TextSpan::from(" Change field "),
TextSpan::from("<TAB>").bold().fg(key_color),
TextSpan::from(" Switch tab "),
TextSpan::from("<BACKTAB>").bold().fg(key_color),
TextSpan::from(" Switch form "),
TextSpan::from("<ENTER>").bold().fg(key_color),
TextSpan::from(" Submit form "),
TextSpan::from("<F10|ESC>").bold().fg(key_color),
TextSpan::from(" Quit "),
SpanStatic::from("<F1|CTRL+H>").bold().fg(key_color),
SpanStatic::from(" Help "),
SpanStatic::from("<CTRL+C>").bold().fg(key_color),
SpanStatic::from(" Enter setup "),
SpanStatic::from("<UP/DOWN>").bold().fg(key_color),
SpanStatic::from(" Change field "),
SpanStatic::from("<TAB>").bold().fg(key_color),
SpanStatic::from(" Switch tab "),
SpanStatic::from("<BACKTAB>").bold().fg(key_color),
SpanStatic::from(" Switch form "),
SpanStatic::from("<ENTER>").bold().fg(key_color),
SpanStatic::from(" Submit form "),
SpanStatic::from("<F10|ESC>").bold().fg(key_color),
SpanStatic::from(" Quit "),
]),
}
}
}
impl Component<Msg, NoUserEvent> for HelpFooter {
fn on(&mut self, _ev: Event<NoUserEvent>) -> Option<Msg> {
impl AppComponent<Msg, NoUserEvent> for HelpFooter {
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
None
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+679
View File
@@ -0,0 +1,679 @@
use super::*;
use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES;
impl AuthActivity {
/// Make text span from bookmarks
pub(in crate::ui::activities::auth) fn view_bookmarks(&mut self) {
let bookmarks: Vec<String> = self
.bookmarks_list
.iter()
.map(|x| {
Self::fmt_bookmark(x, self.bookmarks_client().unwrap().get_bookmark(x).unwrap())
})
.collect();
let bookmarks_color = self.theme().auth_bookmarks;
if let Err(err) = self.app.remount(
Id::BookmarksList,
Box::new(components::BookmarksList::new(&bookmarks, bookmarks_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
/// View recent connections
pub(in crate::ui::activities::auth) fn view_recent_connections(&mut self) {
let bookmarks: Vec<String> = self
.recents_list
.iter()
.map(|x| Self::fmt_recent(self.bookmarks_client().unwrap().get_recent(x).unwrap()))
.collect();
let recents_color = self.theme().auth_recents;
if let Err(err) = self.app.remount(
Id::RecentsList,
Box::new(components::RecentsList::new(&bookmarks, recents_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_error<S: AsRef<str>>(&mut self, text: S) {
let err_color = self.theme().misc_error_dialog;
if let Err(err) = self.app.remount(
Id::ErrorPopup,
Box::new(components::ErrorPopup::new(text, err_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::ErrorPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_error(&mut self) {
let _ = self.app.umount(&Id::ErrorPopup);
}
pub(in crate::ui::activities::auth) fn mount_info<S: AsRef<str>>(&mut self, text: S) {
let color = self.theme().misc_info_dialog;
if let Err(err) = self.app.remount(
Id::InfoPopup,
Box::new(components::InfoPopup::new(text, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::InfoPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_info(&mut self) {
let _ = self.app.umount(&Id::InfoPopup);
}
pub(in crate::ui::activities::auth) fn mount_wait(&mut self, text: &str) {
let wait_color = self.theme().misc_info_dialog;
if let Err(err) = self.app.remount(
Id::WaitPopup,
Box::new(components::WaitPopup::new(text, wait_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::WaitPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_wait(&mut self) {
let _ = self.app.umount(&Id::WaitPopup);
}
pub(in crate::ui::activities::auth) fn mount_size_err(&mut self) {
if let Err(err) = self.app.remount(
Id::WindowSizeError,
Box::new(components::WindowSizeError::new(Color::Red)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::WindowSizeError) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_size_err(&mut self) {
let _ = self.app.umount(&Id::WindowSizeError);
}
pub(in crate::ui::activities::auth) fn mount_quit(&mut self) {
let quit_color = self.theme().misc_quit_dialog;
if let Err(err) = self.app.remount(
Id::QuitPopup,
Box::new(components::QuitPopup::new(quit_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::QuitPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_quit(&mut self) {
let _ = self.app.umount(&Id::QuitPopup);
}
pub(in crate::ui::activities::auth) fn mount_bookmark_del_dialog(&mut self) {
let warn_color = self.theme().misc_warn_dialog;
if let Err(err) = self.app.remount(
Id::DeleteBookmarkPopup,
Box::new(components::DeleteBookmarkPopup::new(warn_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::DeleteBookmarkPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_bookmark_del_dialog(&mut self) {
let _ = self.app.umount(&Id::DeleteBookmarkPopup);
}
pub(in crate::ui::activities::auth) fn mount_recent_del_dialog(&mut self) {
let warn_color = self.theme().misc_warn_dialog;
if let Err(err) = self.app.remount(
Id::DeleteRecentPopup,
Box::new(components::DeleteRecentPopup::new(warn_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::DeleteRecentPopup) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_recent_del_dialog(&mut self) {
let _ = self.app.umount(&Id::DeleteRecentPopup);
}
pub(in crate::ui::activities::auth) fn mount_bookmark_save_dialog(
&mut self,
form_tab: FormTab,
) {
let save_color = self.theme().misc_save_dialog;
let warn_color = self.theme().misc_warn_dialog;
if let Err(err) = self.app.remount(
Id::BookmarkName,
Box::new(components::BookmarkName::new(form_tab, save_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.remount(
Id::BookmarkSavePassword,
Box::new(components::BookmarkSavePassword::new(form_tab, warn_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::BookmarkName) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_bookmark_save_dialog(&mut self) {
let _ = self.app.umount(&Id::BookmarkName);
let _ = self.app.umount(&Id::BookmarkSavePassword);
}
pub(in crate::ui::activities::auth) fn mount_keybindings(&mut self) {
let key_color = self.theme().misc_keys;
if let Err(err) = self.app.remount(
Id::Keybindings,
Box::new(components::Keybindings::new(key_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::Keybindings) {
error!("Failed to activate component: {err}");
}
}
pub(in crate::ui::activities::auth) fn umount_help(&mut self) {
let _ = self.app.umount(&Id::Keybindings);
}
pub(in crate::ui::activities::auth) fn mount_release_notes(&mut self) {
if let Some(ctx) = self.context.as_ref()
&& let Some(release_notes) = ctx.store().get_string(STORE_KEY_RELEASE_NOTES)
{
let info_color = self.theme().misc_info_dialog;
if let Err(err) = self.app.remount(
Id::NewVersionChangelog,
Box::new(components::ReleaseNotes::new(release_notes, info_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.remount(
Id::InstallUpdatePopup,
Box::new(components::InstallUpdatePopup::new(info_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
if let Err(err) = self.app.active(&Id::InstallUpdatePopup) {
error!("Failed to activate component: {err}");
}
}
}
pub(in crate::ui::activities::auth) fn umount_release_notes(&mut self) {
let _ = self.app.umount(&Id::NewVersionChangelog);
let _ = self.app.umount(&Id::InstallUpdatePopup);
}
pub(in crate::ui::activities::auth) fn mount_host_bridge_protocol(
&mut self,
protocol: HostBridgeProtocol,
) {
let protocol_color = self.theme().auth_protocol;
if let Err(err) = self.app.remount(
Id::HostBridge(AuthFormId::Protocol),
Box::new(components::HostBridgeProtocolRadio::new(
protocol,
protocol_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_remote_protocol(
&mut self,
protocol: FileTransferProtocol,
) {
let protocol_color = self.theme().auth_protocol;
if let Err(err) = self.app.remount(
Id::Remote(AuthFormId::Protocol),
Box::new(components::RemoteProtocolRadio::new(
protocol,
protocol_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_remote_directory<S: AsRef<str>>(
&mut self,
form_tab: FormTab,
remote_path: S,
) {
let id = Self::form_tab_id(form_tab, AuthFormId::RemoteDirectory);
let protocol_color = self.theme().auth_protocol;
if let Err(err) = self.app.remount(
id,
Box::new(components::InputRemoteDirectory::new(
remote_path.as_ref(),
form_tab,
protocol_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_local_directory<S: AsRef<str>>(
&mut self,
form_tab: FormTab,
local_path: S,
) {
let id = Self::form_tab_id(form_tab, AuthFormId::LocalDirectory);
let color = self.theme().auth_username;
if let Err(err) = self.app.remount(
id,
Box::new(components::InputLocalDirectory::new(
local_path.as_ref(),
form_tab,
color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_address(
&mut self,
form_tab: FormTab,
address: &str,
) {
let addr_color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::Address);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputAddress::new(address, form_tab, addr_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_port(&mut self, form_tab: FormTab, port: u16) {
let port_color = self.theme().auth_port;
let id = Self::form_tab_id(form_tab, AuthFormId::Port);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputPort::new(port, form_tab, port_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_username(
&mut self,
form_tab: FormTab,
username: &str,
) {
let username_color = self.theme().auth_username;
let id = Self::form_tab_id(form_tab, AuthFormId::Username);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputUsername::new(
username,
form_tab,
username_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_password(
&mut self,
form_tab: FormTab,
password: &str,
) {
let password_color = self.theme().auth_password;
let id = Self::form_tab_id(form_tab, AuthFormId::Password);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputPassword::new(
password,
form_tab,
password_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_bucket(
&mut self,
form_tab: FormTab,
bucket: &str,
) {
let addr_color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::S3Bucket);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3Bucket::new(bucket, form_tab, addr_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_region(
&mut self,
form_tab: FormTab,
region: &str,
) {
let port_color = self.theme().auth_port;
let id = Self::form_tab_id(form_tab, AuthFormId::S3Region);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3Region::new(region, form_tab, port_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_endpoint(
&mut self,
form_tab: FormTab,
endpoint: &str,
) {
let username_color = self.theme().auth_username;
let id = Self::form_tab_id(form_tab, AuthFormId::S3Endpoint);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3Endpoint::new(
endpoint,
form_tab,
username_color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_profile(
&mut self,
form_tab: FormTab,
profile: &str,
) {
let color = self.theme().auth_password;
let id = Self::form_tab_id(form_tab, AuthFormId::S3Profile);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3Profile::new(profile, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_access_key(
&mut self,
form_tab: FormTab,
key: &str,
) {
let color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::S3AccessKey);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3AccessKey::new(key, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_secret_access_key(
&mut self,
form_tab: FormTab,
key: &str,
) {
let color = self.theme().auth_port;
let id = Self::form_tab_id(form_tab, AuthFormId::S3SecretAccessKey);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3SecretAccessKey::new(
key, form_tab, color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_security_token(
&mut self,
form_tab: FormTab,
token: &str,
) {
let color = self.theme().auth_username;
let id = Self::form_tab_id(form_tab, AuthFormId::S3SecurityToken);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3SecurityToken::new(
token, form_tab, color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_session_token(
&mut self,
form_tab: FormTab,
token: &str,
) {
let color = self.theme().auth_password;
let id = Self::form_tab_id(form_tab, AuthFormId::S3SessionToken);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputS3SessionToken::new(token, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_s3_new_path_style(
&mut self,
form_tab: FormTab,
new_path_style: bool,
) {
let color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::S3NewPathStyle);
if let Err(err) = self.app.remount(
id,
Box::new(components::RadioS3NewPathStyle::new(
new_path_style,
form_tab,
color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_kube_namespace(
&mut self,
form_tab: FormTab,
value: &str,
) {
let color = self.theme().auth_port;
let id = Self::form_tab_id(form_tab, AuthFormId::KubeNamespace);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputKubeNamespace::new(value, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_kube_cluster_url(
&mut self,
form_tab: FormTab,
value: &str,
) {
let color = self.theme().auth_username;
let id = Self::form_tab_id(form_tab, AuthFormId::KubeClusterUrl);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputKubeClusterUrl::new(value, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_kube_username(
&mut self,
form_tab: FormTab,
value: &str,
) {
let color = self.theme().auth_password;
let id = Self::form_tab_id(form_tab, AuthFormId::KubeUsername);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputKubeUsername::new(value, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_kube_client_cert(
&mut self,
form_tab: FormTab,
value: &str,
) {
let color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::KubeClientCert);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputKubeClientCert::new(value, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_kube_client_key(
&mut self,
form_tab: FormTab,
value: &str,
) {
let color = self.theme().auth_port;
let id = Self::form_tab_id(form_tab, AuthFormId::KubeClientKey);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputKubeClientKey::new(value, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_smb_share(
&mut self,
form_tab: FormTab,
share: &str,
) {
let color = self.theme().auth_password;
let id = Self::form_tab_id(form_tab, AuthFormId::SmbShare);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputSmbShare::new(share, form_tab, color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn mount_smb_workgroup(
&mut self,
form_tab: FormTab,
workgroup: &str,
) {
let color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::SmbWorkgroup);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputSmbWorkgroup::new(
workgroup, form_tab, color,
)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn mount_webdav_uri(
&mut self,
form_tab: FormTab,
uri: &str,
) {
let addr_color = self.theme().auth_address;
let id = Self::form_tab_id(form_tab, AuthFormId::WebDAVUri);
if let Err(err) = self.app.remount(
id,
Box::new(components::InputWebDAVUri::new(uri, form_tab, addr_color)),
vec![],
) {
error!("Failed to remount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn form_tab_id(form_tab: FormTab, id: AuthFormId) -> Id {
match form_tab {
FormTab::HostBridge => Id::HostBridge(id),
FormTab::Remote => Id::Remote(id),
}
}
}
+512
View File
@@ -0,0 +1,512 @@
use std::path::PathBuf;
use std::str::FromStr;
use tuirealm::state::{State, StateValue};
use super::*;
use crate::filetransfer::FileTransferParams;
use crate::filetransfer::params::{
AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, SmbParams,
WebDAVProtocolParams,
};
impl AuthActivity {
pub(in crate::ui::activities::auth) fn get_generic_params_input(
&self,
form_tab: FormTab,
) -> GenericProtocolParams {
let addr = self.get_input_addr(form_tab);
let port = self.get_input_port(form_tab);
let username = self.get_input_username(form_tab);
let password = self.get_input_password(form_tab);
GenericProtocolParams::default()
.address(addr)
.port(port)
.username(username)
.password(password)
}
pub(in crate::ui::activities::auth) fn get_s3_params_input(
&self,
form_tab: FormTab,
) -> AwsS3Params {
let bucket = self.get_input_s3_bucket(form_tab);
let region = self.get_input_s3_region(form_tab);
let endpoint = self.get_input_s3_endpoint(form_tab);
let profile = self.get_input_s3_profile(form_tab);
let access_key = self.get_input_s3_access_key(form_tab);
let secret_access_key = self.get_input_s3_secret_access_key(form_tab);
let security_token = self.get_input_s3_security_token(form_tab);
let session_token = self.get_input_s3_session_token(form_tab);
let new_path_style = self.get_input_s3_new_path_style(form_tab);
AwsS3Params::new(bucket, region, profile)
.endpoint(endpoint)
.access_key(access_key)
.secret_access_key(secret_access_key)
.security_token(security_token)
.session_token(session_token)
.new_path_style(new_path_style)
}
pub(in crate::ui::activities::auth) fn get_kube_params_input(
&self,
form_tab: FormTab,
) -> KubeProtocolParams {
let namespace = self.get_input_kube_namespace(form_tab);
let cluster_url = self.get_input_kube_cluster_url(form_tab);
let username = self.get_input_kube_username(form_tab);
let client_cert = self.get_input_kube_client_cert(form_tab);
let client_key = self.get_input_kube_client_key(form_tab);
KubeProtocolParams {
namespace,
cluster_url,
username,
client_cert,
client_key,
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn get_smb_params_input(
&self,
form_tab: FormTab,
) -> SmbParams {
let share = self.get_input_smb_share(form_tab);
let workgroup = self.get_input_smb_workgroup(form_tab);
let address = self.get_input_addr(form_tab);
let port = self.get_input_port(form_tab);
let username = self.get_input_username(form_tab);
let password = self.get_input_password(form_tab);
SmbParams::new(address, share)
.port(port)
.username(username)
.password(password)
.workgroup(workgroup)
}
#[cfg(win)]
pub(in crate::ui::activities::auth) fn get_smb_params_input(
&self,
form_tab: FormTab,
) -> SmbParams {
let share = self.get_input_smb_share(form_tab);
let address = self.get_input_addr(form_tab);
let username = self.get_input_username(form_tab);
let password = self.get_input_password(form_tab);
SmbParams::new(address, share)
.username(username)
.password(password)
}
pub(in crate::ui::activities::auth) fn get_webdav_params_input(
&self,
form_tab: FormTab,
) -> WebDAVProtocolParams {
let uri = self.get_webdav_uri(form_tab);
let username = self.get_input_username(form_tab).unwrap_or_default();
let password = self.get_input_password(form_tab).unwrap_or_default();
WebDAVProtocolParams {
uri,
username,
password,
}
}
pub(in crate::ui::activities::auth) fn get_input_remote_directory(
&self,
form_tab: FormTab,
) -> Option<PathBuf> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::RemoteDirectory))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => {
Some(PathBuf::from(x.as_str()))
}
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_local_directory(
&self,
form_tab: FormTab,
) -> Option<PathBuf> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::LocalDirectory))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => {
Some(PathBuf::from(x.as_str()))
}
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_webdav_uri(&self, form_tab: FormTab) -> String {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::WebDAVUri))
{
Ok(State::Single(StateValue::String(x))) => x,
_ => String::new(),
}
}
pub(in crate::ui::activities::auth) fn get_input_addr(&self, form_tab: FormTab) -> String {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::Address))
{
Ok(State::Single(StateValue::String(x))) => x,
_ => String::new(),
}
}
pub(in crate::ui::activities::auth) fn get_input_port(&self, form_tab: FormTab) -> u16 {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::Port))
{
Ok(State::Single(StateValue::String(x))) => {
u16::from_str(x.as_str()).unwrap_or_default()
}
_ => 0,
}
}
pub(in crate::ui::activities::auth) fn get_input_username(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::Username))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_password(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::Password))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_bucket(&self, form_tab: FormTab) -> String {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3Bucket))
{
Ok(State::Single(StateValue::String(x))) => x,
_ => String::new(),
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_region(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3Region))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_endpoint(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3Endpoint))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_profile(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3Profile))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_access_key(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3AccessKey))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_secret_access_key(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3SecretAccessKey))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_security_token(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3SecurityToken))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_session_token(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3SessionToken))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_s3_new_path_style(
&self,
form_tab: FormTab,
) -> bool {
matches!(
self.app
.state(&Self::form_tab_id(form_tab, AuthFormId::S3NewPathStyle)),
Ok(State::Single(StateValue::Usize(0)))
)
}
pub(in crate::ui::activities::auth) fn get_input_kube_namespace(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::KubeNamespace))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_kube_cluster_url(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::KubeClusterUrl))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_kube_username(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::KubeUsername))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_kube_client_cert(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::KubeClientCert))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_kube_client_key(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::KubeClientKey))
{
Ok(State::Single(StateValue::String(x))) if !x.is_empty() => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_input_smb_share(&self, form_tab: FormTab) -> String {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::SmbShare))
{
Ok(State::Single(StateValue::String(x))) => x,
_ => String::new(),
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn get_input_smb_workgroup(
&self,
form_tab: FormTab,
) -> Option<String> {
match self
.app
.state(&Self::form_tab_id(form_tab, AuthFormId::SmbWorkgroup))
{
Ok(State::Single(StateValue::String(x))) => Some(x),
_ => None,
}
}
pub(in crate::ui::activities::auth) fn get_new_bookmark(&self) -> (String, bool) {
let name = match self.app.state(&Id::BookmarkName) {
Ok(State::Single(StateValue::String(name))) => name,
_ => String::default(),
};
if matches!(
self.app.state(&Id::BookmarkSavePassword),
Ok(State::Single(StateValue::Usize(0)))
) {
(name, true)
} else {
(name, false)
}
}
pub(in crate::ui::activities::auth) fn max_input_mask_size(&self) -> u16 {
Self::input_mask_size(self.host_bridge_input_mask())
.max(Self::input_mask_size(self.remote_input_mask()))
+ 3
}
fn input_mask_size(input_mask: InputMask) -> u16 {
match input_mask {
InputMask::AwsS3
| InputMask::Generic
| InputMask::Kube
| InputMask::Smb
| InputMask::WebDAV => 12,
InputMask::Localhost => 3,
}
}
pub(in crate::ui::activities::auth) fn fmt_bookmark(
name: &str,
b: FileTransferParams,
) -> String {
let addr = Self::fmt_recent(b);
format!("{name} ({addr})")
}
pub(in crate::ui::activities::auth) fn fmt_recent(b: FileTransferParams) -> String {
let protocol = b.protocol.to_string().to_lowercase();
match b.params {
ProtocolParams::AwsS3(s3) => {
let profile = match s3.profile {
Some(p) => format!("[{p}]"),
None => String::default(),
};
format!(
"{}://{}{} ({}) {}",
protocol,
s3.endpoint.unwrap_or_default(),
s3.bucket_name,
s3.region.as_deref().unwrap_or("custom"),
profile
)
}
ProtocolParams::Generic(params) => {
let username = match params.username {
None => String::default(),
Some(u) => format!("{u}@"),
};
format!(
"{}://{}{}:{}",
protocol, username, params.address, params.port
)
}
ProtocolParams::Kube(params) => {
format!(
"{}://{}{}",
protocol,
params
.namespace
.as_deref()
.map(|x| format!("/{x}"))
.unwrap_or_else(|| String::from("default")),
params
.cluster_url
.as_deref()
.map(|x| format!("@{x}"))
.unwrap_or_default()
)
}
#[cfg(posix)]
ProtocolParams::Smb(params) => {
let username = match params.username {
None => String::default(),
Some(u) => format!("{u}@"),
};
format!(
"\\\\{username}{}:{}\\{}",
params.address, params.port, params.share
)
}
#[cfg(win)]
ProtocolParams::Smb(params) => {
let username = match params.username {
None => String::default(),
Some(u) => format!("{u}@"),
};
format!("\\\\{username}{}\\{}", params.address, params.share)
}
ProtocolParams::WebDAV(params) => params.uri,
}
}
}
+512
View File
@@ -0,0 +1,512 @@
use tuirealm::subscription::{EventClause, Sub, SubClause};
use super::*;
impl AuthActivity {
pub(in crate::ui::activities::auth) fn get_host_bridge_generic_params_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::Port),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::Address),
Id::HostBridge(AuthFormId::Port),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
],
}
}
pub(in crate::ui::activities::auth) fn get_remote_generic_params_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::Port),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::Address),
Id::Remote(AuthFormId::Port),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
],
}
}
pub(in crate::ui::activities::auth) fn get_host_bridge_localhost_view(&self) -> [Id; 1] {
[Id::HostBridge(AuthFormId::LocalDirectory)]
}
pub(in crate::ui::activities::auth) fn get_host_bridge_s3_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::HostBridge(AuthFormId::S3AccessKey)) => [
Id::HostBridge(AuthFormId::S3Region),
Id::HostBridge(AuthFormId::S3Endpoint),
Id::HostBridge(AuthFormId::S3Profile),
Id::HostBridge(AuthFormId::S3AccessKey),
],
Some(&Id::HostBridge(AuthFormId::S3SecretAccessKey)) => [
Id::HostBridge(AuthFormId::S3Endpoint),
Id::HostBridge(AuthFormId::S3Profile),
Id::HostBridge(AuthFormId::S3AccessKey),
Id::HostBridge(AuthFormId::S3SecretAccessKey),
],
Some(&Id::HostBridge(AuthFormId::S3SecurityToken)) => [
Id::HostBridge(AuthFormId::S3Profile),
Id::HostBridge(AuthFormId::S3AccessKey),
Id::HostBridge(AuthFormId::S3SecretAccessKey),
Id::HostBridge(AuthFormId::S3SecurityToken),
],
Some(&Id::HostBridge(AuthFormId::S3SessionToken)) => [
Id::HostBridge(AuthFormId::S3AccessKey),
Id::HostBridge(AuthFormId::S3SecretAccessKey),
Id::HostBridge(AuthFormId::S3SecurityToken),
Id::HostBridge(AuthFormId::S3SessionToken),
],
Some(&Id::HostBridge(AuthFormId::S3NewPathStyle)) => [
Id::HostBridge(AuthFormId::S3SecretAccessKey),
Id::HostBridge(AuthFormId::S3SecurityToken),
Id::HostBridge(AuthFormId::S3SessionToken),
Id::HostBridge(AuthFormId::S3NewPathStyle),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::S3SecurityToken),
Id::HostBridge(AuthFormId::S3SessionToken),
Id::HostBridge(AuthFormId::S3NewPathStyle),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::S3SessionToken),
Id::HostBridge(AuthFormId::S3NewPathStyle),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::S3Bucket),
Id::HostBridge(AuthFormId::S3Region),
Id::HostBridge(AuthFormId::S3Endpoint),
Id::HostBridge(AuthFormId::S3Profile),
],
}
}
pub(in crate::ui::activities::auth) fn get_remote_s3_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::Remote(AuthFormId::S3AccessKey)) => [
Id::Remote(AuthFormId::S3Region),
Id::Remote(AuthFormId::S3Endpoint),
Id::Remote(AuthFormId::S3Profile),
Id::Remote(AuthFormId::S3AccessKey),
],
Some(&Id::Remote(AuthFormId::S3SecretAccessKey)) => [
Id::Remote(AuthFormId::S3Endpoint),
Id::Remote(AuthFormId::S3Profile),
Id::Remote(AuthFormId::S3AccessKey),
Id::Remote(AuthFormId::S3SecretAccessKey),
],
Some(&Id::Remote(AuthFormId::S3SecurityToken)) => [
Id::Remote(AuthFormId::S3Profile),
Id::Remote(AuthFormId::S3AccessKey),
Id::Remote(AuthFormId::S3SecretAccessKey),
Id::Remote(AuthFormId::S3SecurityToken),
],
Some(&Id::Remote(AuthFormId::S3SessionToken)) => [
Id::Remote(AuthFormId::S3AccessKey),
Id::Remote(AuthFormId::S3SecretAccessKey),
Id::Remote(AuthFormId::S3SecurityToken),
Id::Remote(AuthFormId::S3SessionToken),
],
Some(&Id::Remote(AuthFormId::S3NewPathStyle)) => [
Id::Remote(AuthFormId::S3SecretAccessKey),
Id::Remote(AuthFormId::S3SecurityToken),
Id::Remote(AuthFormId::S3SessionToken),
Id::Remote(AuthFormId::S3NewPathStyle),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::S3SecurityToken),
Id::Remote(AuthFormId::S3SessionToken),
Id::Remote(AuthFormId::S3NewPathStyle),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::S3SessionToken),
Id::Remote(AuthFormId::S3NewPathStyle),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::S3Bucket),
Id::Remote(AuthFormId::S3Region),
Id::Remote(AuthFormId::S3Endpoint),
Id::Remote(AuthFormId::S3Profile),
],
}
}
pub(in crate::ui::activities::auth) fn get_host_bridge_kube_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::HostBridge(AuthFormId::KubeClientCert)) => [
Id::HostBridge(AuthFormId::KubeNamespace),
Id::HostBridge(AuthFormId::KubeClusterUrl),
Id::HostBridge(AuthFormId::KubeUsername),
Id::HostBridge(AuthFormId::KubeClientCert),
],
Some(&Id::HostBridge(AuthFormId::KubeClientKey)) => [
Id::HostBridge(AuthFormId::KubeClusterUrl),
Id::HostBridge(AuthFormId::KubeUsername),
Id::HostBridge(AuthFormId::KubeClientCert),
Id::HostBridge(AuthFormId::KubeClientKey),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::KubeUsername),
Id::HostBridge(AuthFormId::KubeClientCert),
Id::HostBridge(AuthFormId::KubeClientKey),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::KubeClientCert),
Id::HostBridge(AuthFormId::KubeClientKey),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::KubeNamespace),
Id::HostBridge(AuthFormId::KubeClusterUrl),
Id::HostBridge(AuthFormId::KubeUsername),
Id::HostBridge(AuthFormId::KubeClientCert),
],
}
}
pub(in crate::ui::activities::auth) fn get_remote_kube_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::Remote(AuthFormId::KubeClientCert)) => [
Id::Remote(AuthFormId::KubeNamespace),
Id::Remote(AuthFormId::KubeClusterUrl),
Id::Remote(AuthFormId::KubeUsername),
Id::Remote(AuthFormId::KubeClientCert),
],
Some(&Id::Remote(AuthFormId::KubeClientKey)) => [
Id::Remote(AuthFormId::KubeClusterUrl),
Id::Remote(AuthFormId::KubeUsername),
Id::Remote(AuthFormId::KubeClientCert),
Id::Remote(AuthFormId::KubeClientKey),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::KubeUsername),
Id::Remote(AuthFormId::KubeClientCert),
Id::Remote(AuthFormId::KubeClientKey),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::KubeClientCert),
Id::Remote(AuthFormId::KubeClientKey),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::KubeNamespace),
Id::Remote(AuthFormId::KubeClusterUrl),
Id::Remote(AuthFormId::KubeUsername),
Id::Remote(AuthFormId::KubeClientCert),
],
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn get_host_bridge_smb_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(
&Id::HostBridge(AuthFormId::Address)
| &Id::HostBridge(AuthFormId::Port)
| &Id::HostBridge(AuthFormId::SmbShare)
| &Id::HostBridge(AuthFormId::Username),
) => [
Id::HostBridge(AuthFormId::Address),
Id::HostBridge(AuthFormId::Port),
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
],
Some(&Id::HostBridge(AuthFormId::Password)) => [
Id::HostBridge(AuthFormId::Port),
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
],
Some(&Id::HostBridge(AuthFormId::SmbWorkgroup)) => [
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::SmbWorkgroup),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::Address),
Id::HostBridge(AuthFormId::Port),
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
],
}
}
#[cfg(posix)]
pub(in crate::ui::activities::auth) fn get_remote_smb_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(
&Id::Remote(AuthFormId::Address)
| &Id::Remote(AuthFormId::Port)
| &Id::Remote(AuthFormId::SmbShare)
| &Id::Remote(AuthFormId::Username),
) => [
Id::Remote(AuthFormId::Address),
Id::Remote(AuthFormId::Port),
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
],
Some(&Id::Remote(AuthFormId::Password)) => [
Id::Remote(AuthFormId::Port),
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
],
Some(&Id::Remote(AuthFormId::SmbWorkgroup)) => [
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::SmbWorkgroup),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::Address),
Id::Remote(AuthFormId::Port),
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
],
}
}
#[cfg(win)]
pub(in crate::ui::activities::auth) fn get_host_bridge_smb_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(
&Id::HostBridge(AuthFormId::Address)
| &Id::HostBridge(AuthFormId::Password)
| &Id::HostBridge(AuthFormId::SmbShare)
| &Id::HostBridge(AuthFormId::Username),
) => [
Id::HostBridge(AuthFormId::Address),
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
],
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::Address),
Id::HostBridge(AuthFormId::SmbShare),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
],
}
}
#[cfg(win)]
pub(in crate::ui::activities::auth) fn get_remote_smb_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(
&Id::Remote(AuthFormId::Address)
| &Id::Remote(AuthFormId::Password)
| &Id::Remote(AuthFormId::SmbShare)
| &Id::Remote(AuthFormId::Username),
) => [
Id::Remote(AuthFormId::Address),
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
],
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
],
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::Address),
Id::Remote(AuthFormId::SmbShare),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
],
}
}
pub(in crate::ui::activities::auth) fn get_host_bridge_webdav_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
Id::HostBridge(AuthFormId::LocalDirectory),
],
_ => [
Id::HostBridge(AuthFormId::WebDAVUri),
Id::HostBridge(AuthFormId::Username),
Id::HostBridge(AuthFormId::Password),
Id::HostBridge(AuthFormId::RemoteDirectory),
],
}
}
pub(in crate::ui::activities::auth) fn get_remote_webdav_view(&self) -> [Id; 4] {
match self.app.focus() {
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
Id::Remote(AuthFormId::LocalDirectory),
],
_ => [
Id::Remote(AuthFormId::WebDAVUri),
Id::Remote(AuthFormId::Username),
Id::Remote(AuthFormId::Password),
Id::Remote(AuthFormId::RemoteDirectory),
],
}
}
pub(in crate::ui::activities::auth) fn init_global_listener(&mut self) {
use tuirealm::event::{Key, KeyEvent, KeyModifiers};
if let Err(err) = self.app.mount(
Id::GlobalListener,
Box::<components::GlobalListener>::default(),
vec![
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Esc,
modifiers: KeyModifiers::NONE,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Function(10),
modifiers: KeyModifiers::NONE,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Char('c'),
modifiers: KeyModifiers::CONTROL,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Char('h'),
modifiers: KeyModifiers::CONTROL,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Function(1),
modifiers: KeyModifiers::NONE,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Char('r'),
modifiers: KeyModifiers::CONTROL,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(
EventClause::Keyboard(KeyEvent {
code: Key::Char('s'),
modifiers: KeyModifiers::CONTROL,
}),
Self::no_popup_mounted_clause(),
),
Sub::new(EventClause::WindowResize, SubClause::Always),
],
) {
error!("Failed to mount component: {err}");
}
}
pub(in crate::ui::activities::auth) fn get_current_form_tab(&self) -> FormTab {
match self.app.focus() {
Some(&Id::HostBridge(_)) => FormTab::HostBridge,
_ => FormTab::Remote,
}
}
fn no_popup_mounted_clause() -> SubClause<Id> {
tuirealm::subclause_and_not!(
Id::ErrorPopup,
Id::InfoPopup,
Id::Keybindings,
Id::DeleteBookmarkPopup,
Id::DeleteRecentPopup,
Id::InstallUpdatePopup,
Id::BookmarkSavePassword,
Id::WaitPopup
)
}
}
@@ -1,6 +1,6 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// This module is split into files, cause it's just too big
mod actions;
@@ -20,13 +20,16 @@ use std::time::Duration;
// Includes
use chrono::{DateTime, Local};
use lib::browser;
use lib::browser::Browser;
use lib::browser::{Browser, FileExplorerTab};
use lib::pane::Pane;
use lib::transfer::{TransferOpts, TransferStates};
use lib::walkdir::WalkdirStates;
use remotefs::RemoteFs;
use session::TransferPayload;
use tempfile::TempDir;
use tuirealm::{Application, EventListenerCfg, NoUserEvent};
use tuirealm::application::Application;
use tuirealm::event::NoUserEvent;
use tuirealm::listener::EventListenerCfg;
use tuirealm::terminal::TerminalAdapter;
use super::{Activity, CROSSTERM_MAX_POLL, Context, ExitReason};
use crate::config::themes::Theme;
@@ -34,7 +37,7 @@ use crate::explorer::{FileExplorer, FileSorting};
use crate::filetransfer::{
FileTransferParams, HostBridgeBuilder, HostBridgeParams, RemoteFsBuilder,
};
use crate::host::HostBridge;
use crate::host::RemoteBridged;
use crate::system::config_client::ConfigClient;
use crate::system::watcher::FsWatcher;
@@ -67,8 +70,7 @@ enum Id {
MkdirPopup,
NewfilePopup,
OpenWithPopup,
ProgressBarFull,
ProgressBarPartial,
TransferProgressBar,
QuitPopup,
RenamePopup,
ReplacePopup,
@@ -237,10 +239,6 @@ pub struct FileTransferActivity {
app: Application<Id, Msg, NoUserEvent>,
/// Whether should redraw UI
redraw: bool,
/// Host bridge
host_bridge: Box<dyn HostBridge>,
/// Remote host client
client: Box<dyn RemoteFs>,
/// Browser
browser: Browser,
/// Current log lines
@@ -253,10 +251,6 @@ pub struct FileTransferActivity {
cache: Option<TempDir>,
/// Fs watcher
fswatcher: Option<FsWatcher>,
/// host bridge connected
host_bridge_connected: bool,
/// remote connected once
remote_connected: bool,
}
impl FileTransferActivity {
@@ -272,23 +266,34 @@ impl FileTransferActivity {
let host_bridge = HostBridgeBuilder::build(host_bridge_params, &config_client)?;
let host_bridge_connected = host_bridge.is_localhost();
let enable_fs_watcher = host_bridge.is_localhost();
// Build remote client, wrapped as HostBridge via RemoteBridged
let remote_client = RemoteFsBuilder::build(
remote_params.protocol,
remote_params.params.clone(),
&config_client,
)?;
let remote_fs: Box<dyn crate::host::HostBridge> =
Box::new(RemoteBridged::from(remote_client));
// Build panes
let local_pane = Pane::new(
Browser::build_local_explorer(&config_client),
host_bridge_connected,
host_bridge,
);
let remote_pane = Pane::new(
Browser::build_remote_explorer(&config_client),
false,
remote_fs,
);
Ok(Self {
exit_reason: None,
context: None,
app: Application::init(
EventListenerCfg::default()
.poll_timeout(ticks)
.crossterm_input_listener(ticks, CROSSTERM_MAX_POLL),
EventListenerCfg::default().crossterm_input_listener(ticks, CROSSTERM_MAX_POLL),
),
redraw: true,
host_bridge,
client: RemoteFsBuilder::build(
remote_params.protocol,
remote_params.params.clone(),
&config_client,
)?,
browser: Browser::new(&config_client),
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
browser: Browser::new(local_pane, remote_pane),
log_records: VecDeque::with_capacity(256),
walkdir: WalkdirStates::default(),
transfer: TransferStates::default(),
cache: TempDir::new().ok(),
@@ -297,11 +302,18 @@ impl FileTransferActivity {
} else {
None
},
host_bridge_connected,
remote_connected: false,
})
}
/// Returns `true` when the active tab targets the local side
/// (either the main host-bridge pane or a find-result rooted there).
fn is_local_tab(&self) -> bool {
matches!(
self.browser.tab(),
FileExplorerTab::HostBridge | FileExplorerTab::FindHostBridge
)
}
fn host_bridge(&self) -> &FileExplorer {
self.browser.host_bridge()
}
@@ -433,7 +445,7 @@ impl Activity for FileTransferActivity {
error!("Failed to enter raw mode: {}", err);
}
// Get files at current pwd
if self.host_bridge.is_localhost() {
if self.browser.local_pane().fs.is_localhost() {
debug!("Reloading host bridge directory");
self.reload_host_bridge_dir();
}
@@ -460,14 +472,16 @@ impl Activity for FileTransferActivity {
return;
}
// Check if connected to host bridge (popup must be None, otherwise would try reconnecting in loop in case of error)
if (!self.host_bridge.is_connected() || !self.host_bridge_connected)
if (!self.browser.local_pane_mut().fs.is_connected()
|| !self.browser.local_pane().connected)
&& !self.app.mounted(&Id::FatalPopup)
&& !self.host_bridge.is_localhost()
&& !self.browser.local_pane().fs.is_localhost()
{
let host_bridge_params = self.context().host_bridge_params().unwrap();
let ft_params = host_bridge_params.unwrap_protocol_params();
// print params
let msg: String = Self::get_connection_msg(ft_params);
let msg: String = match host_bridge_params.protocol_params() {
Some(ft_params) => Self::get_connection_msg(ft_params),
None => String::from("Connecting..."),
};
// Set init state to connecting popup
self.mount_blocking_wait(msg.as_str());
// Connect to remote
@@ -476,9 +490,10 @@ impl Activity for FileTransferActivity {
self.redraw = true;
}
// Check if connected to remote (popup must be None, otherwise would try reconnecting in loop in case of error)
if (!self.client.is_connected() || !self.remote_connected)
if (!self.browser.remote_pane_mut().fs.is_connected()
|| !self.browser.remote_pane().connected)
&& !self.app.mounted(&Id::FatalPopup)
&& self.host_bridge.is_connected()
&& self.browser.local_pane_mut().fs.is_connected()
{
let ftparams = self.context().remote_params().unwrap();
// print params
@@ -522,14 +537,21 @@ impl Activity for FileTransferActivity {
if let Err(err) = self.context_mut().terminal().clear_screen() {
error!("Failed to clear screen: {}", err);
}
// Disconnect client
if self.client.is_connected() {
let _ = self.client.disconnect();
// Disconnect remote
if self.browser.remote_pane_mut().fs.is_connected() {
let _ = self.browser.remote_pane_mut().fs.disconnect();
}
// disconnect host bridge
if self.host_bridge.is_connected() {
let _ = self.host_bridge.disconnect();
// Disconnect host bridge
if self.browser.local_pane_mut().fs.is_connected() {
let _ = self.browser.local_pane_mut().fs.disconnect();
}
self.context.take()
}
}
/// Log a UI operation error instead of panicking.
fn ui_result<T>(result: Result<T, impl std::fmt::Display>) {
if let Err(err) = result {
error!("UI operation failed: {err}");
}
}
@@ -1,12 +1,12 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use std::path::{Path, PathBuf};
use remotefs::File;
use remotefs::fs::UnixPex;
use tuirealm::{State, StateValue};
use tuirealm::state::{State, StateValue};
use super::browser::FileExplorerTab;
use super::lib::browser::FoundExplorerTab;
@@ -92,43 +92,50 @@ impl FileTransferActivity {
self.get_selected_files(&Id::ExplorerHostBridge)
}
pub(crate) fn get_local_selected_file(&self) -> Option<File> {
self.get_selected_file(&Id::ExplorerHostBridge)
}
/// Get remote file entry
pub(crate) fn get_remote_selected_entries(&mut self) -> SelectedFile {
self.get_selected_files(&Id::ExplorerRemote)
}
pub(crate) fn get_remote_selected_file(&self) -> Option<File> {
self.get_selected_file(&Id::ExplorerRemote)
}
/// Returns whether only one entry is selected on local host
pub(crate) fn is_local_selected_one(&mut self) -> bool {
matches!(self.get_local_selected_entries(), SelectedFile::One(_))
}
/// Returns whether only one entry is selected on remote host
pub(crate) fn is_remote_selected_one(&mut self) -> bool {
matches!(self.get_remote_selected_entries(), SelectedFile::One(_))
}
/// Get remote file entry
pub(crate) fn get_found_selected_entries(&mut self) -> SelectedFile {
self.get_selected_files(&Id::ExplorerFind)
}
pub(crate) fn get_found_selected_file(&self) -> Option<File> {
self.get_selected_file(&Id::ExplorerFind)
self.get_selected_file_by_id(&Id::ExplorerFind)
}
/// Get selected entries from whichever tab is currently active.
pub(crate) fn get_selected_entries(&mut self) -> SelectedFile {
let id = match self.browser.tab() {
FileExplorerTab::HostBridge => Id::ExplorerHostBridge,
FileExplorerTab::Remote => Id::ExplorerRemote,
FileExplorerTab::FindHostBridge | FileExplorerTab::FindRemote => Id::ExplorerFind,
};
self.get_selected_files(&id)
}
/// Get single selected file from whichever tab is currently active.
pub(crate) fn get_selected_file(&self) -> Option<File> {
let id = match self.browser.tab() {
FileExplorerTab::HostBridge => Id::ExplorerHostBridge,
FileExplorerTab::Remote => Id::ExplorerRemote,
FileExplorerTab::FindHostBridge | FileExplorerTab::FindRemote => Id::ExplorerFind,
};
self.get_selected_file_by_id(&id)
}
/// Returns whether only one entry is selected on the current tab.
pub(crate) fn is_selected_one(&mut self) -> bool {
matches!(self.get_selected_entries(), SelectedFile::One(_))
}
// -- private
fn get_selected_index(&self, id: &Id) -> SelectedFileIndex {
match self.app.state(id) {
Ok(State::One(StateValue::Usize(idx))) => SelectedFileIndex::One(idx),
Ok(State::Single(StateValue::Usize(idx))) => SelectedFileIndex::One(idx),
_ => SelectedFileIndex::None,
}
}
@@ -164,13 +171,13 @@ impl FileTransferActivity {
fn get_file_from_path(&mut self, id: &Id, path: &Path) -> Option<File> {
match *id {
Id::ExplorerHostBridge => self.host_bridge.stat(path).ok(),
Id::ExplorerRemote => self.client.stat(path).ok(),
Id::ExplorerHostBridge => self.browser.local_pane_mut().fs.stat(path).ok(),
Id::ExplorerRemote => self.browser.remote_pane_mut().fs.stat(path).ok(),
Id::ExplorerFind => {
let found = self.browser.found_tab().unwrap();
match found {
FoundExplorerTab::Local => self.host_bridge.stat(path).ok(),
FoundExplorerTab::Remote => self.client.stat(path).ok(),
FoundExplorerTab::Local => self.browser.local_pane_mut().fs.stat(path).ok(),
FoundExplorerTab::Remote => self.browser.remote_pane_mut().fs.stat(path).ok(),
}
}
_ => None,
@@ -182,11 +189,16 @@ impl FileTransferActivity {
Id::ExplorerHostBridge => self.host_bridge(),
Id::ExplorerRemote => self.remote(),
Id::ExplorerFind => self.found().as_ref().unwrap(),
_ => unreachable!(),
_ => {
error!(
"browser_by_id called with unexpected id: {id:?}; falling back to local explorer"
);
self.host_bridge()
}
}
}
fn get_selected_file(&self, id: &Id) -> Option<File> {
fn get_selected_file_by_id(&self, id: &Id) -> Option<File> {
let browser = self.browser_by_id(id);
// if no transfer queue, return selected files
match self.get_selected_index(id) {
@@ -1,63 +1,45 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// locals
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use remotefs::File;
use super::{FileExplorerTab, FileTransferActivity, LogLevel, Msg, PendingActionMsg};
use super::{FileTransferActivity, LogLevel, Msg, PendingActionMsg};
/// Describes destination for sync browsing
enum SyncBrowsingDestination {
pub(crate) enum SyncBrowsingDestination {
Path(String),
ParentDir,
PreviousDir,
}
impl FileTransferActivity {
/// Enter a directory on local host from entry
pub(crate) fn action_enter_local_dir(&mut self, dir: File) {
self.host_bridge_changedir(dir.path(), true);
/// Enter a directory from entry, dispatching via the active tab's pane.
pub(crate) fn action_enter_dir(&mut self, dir: File) {
self.pane_changedir(dir.path(), true);
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::Path(dir.name()));
}
}
/// Enter a directory on local host from entry
pub(crate) fn action_enter_remote_dir(&mut self, dir: File) {
self.remote_changedir(dir.path(), true);
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::Path(dir.name()));
}
}
/// Change local directory reading value from input
pub(crate) fn action_change_local_dir(&mut self, input: String) {
let dir_path: PathBuf =
self.host_bridge_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.host_bridge_changedir(dir_path.as_path(), true);
/// Change directory reading value from input, dispatching via the active tab's pane.
pub(crate) fn action_change_dir(&mut self, input: String) {
let dir_path = self.pane_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.pane_changedir(dir_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::Path(input));
}
}
/// Change remote directory reading value from input
pub(crate) fn action_change_remote_dir(&mut self, input: String) {
let dir_path: PathBuf = self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.remote_changedir(dir_path.as_path(), true);
// Check whether to sync
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::Path(input));
}
}
/// Go to previous directory from localhost
pub(crate) fn action_go_to_previous_local_dir(&mut self) {
if let Some(d) = self.host_bridge_mut().popd() {
self.host_bridge_changedir(d.as_path(), false);
/// Go to previous directory, dispatching via the active tab's pane.
pub(crate) fn action_go_to_previous_dir(&mut self) {
let prev = self.browser.fs_pane_mut().explorer.popd();
if let Some(d) = prev {
self.pane_changedir(d.as_path(), false);
// Check whether to sync
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::PreviousDir);
@@ -65,41 +47,12 @@ impl FileTransferActivity {
}
}
/// Go to previous directory from remote host
pub(crate) fn action_go_to_previous_remote_dir(&mut self) {
if let Some(d) = self.remote_mut().popd() {
self.remote_changedir(d.as_path(), false);
// Check whether to sync
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::PreviousDir);
}
}
}
/// Go to upper directory on local host
pub(crate) fn action_go_to_local_upper_dir(&mut self) {
// Get pwd
let path: PathBuf = self.host_bridge().wrkdir.clone();
// Go to parent directory
/// Go to upper directory, dispatching via the active tab's pane.
pub(crate) fn action_go_to_upper_dir(&mut self) {
let path = self.browser.fs_pane().explorer.wrkdir.clone();
if let Some(parent) = path.as_path().parent() {
self.host_bridge_changedir(parent, true);
// If sync is enabled update remote too
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::ParentDir);
}
}
}
/// #### action_go_to_remote_upper_dir
///
/// Go to upper directory on remote host
pub(crate) fn action_go_to_remote_upper_dir(&mut self) {
// Get pwd
let path: PathBuf = self.remote().wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.remote_changedir(parent, true);
// If sync is enabled update local too
self.pane_changedir(parent, true);
// If sync is enabled update the other side too
if self.browser.sync_browsing && self.browser.found().is_none() {
self.synchronize_browsing(SyncBrowsingDestination::ParentDir);
}
@@ -110,16 +63,18 @@ impl FileTransferActivity {
/// Synchronize browsing on the target browser.
/// If destination doesn't exist, then prompt for directory creation.
fn synchronize_browsing(&mut self, destination: SyncBrowsingDestination) {
pub(crate) fn synchronize_browsing(&mut self, destination: SyncBrowsingDestination) {
// Get destination path
let path = match self.resolve_sync_browsing_destination(&destination) {
Some(p) => p,
None => return,
};
trace!("Synchronizing browsing to path {}", path.display());
// Check whether destination exists on host
let exists = match self.browser.tab() {
FileExplorerTab::HostBridge => match self.client.exists(path.as_path()) {
// Check whether destination exists on the opposite side
let is_local = self.is_local_tab();
let exists = if is_local {
// Current tab is local, so the opposite is remote
match self.browser.remote_pane_mut().fs.exists(path.as_path()) {
Ok(e) => e,
Err(err) => {
error!(
@@ -129,8 +84,10 @@ impl FileTransferActivity {
);
return;
}
},
FileExplorerTab::Remote => match self.host_bridge.exists(path.as_path()) {
}
} else {
// Current tab is remote, so the opposite is local
match self.browser.local_pane_mut().fs.exists(path.as_path()) {
Ok(e) => e,
Err(err) => {
error!(
@@ -140,8 +97,7 @@ impl FileTransferActivity {
);
return;
}
},
_ => return,
}
};
let name = path
.file_name()
@@ -159,12 +115,8 @@ impl FileTransferActivity {
]) == Msg::PendingAction(PendingActionMsg::MakePendingDirectory)
{
trace!("User wants to create the unexisting directory");
// Make directory
match self.browser.tab() {
FileExplorerTab::HostBridge => self.action_remote_mkdir(name.clone()),
FileExplorerTab::Remote => self.action_local_mkdir(name.clone()),
_ => {}
}
// Make directory on the opposite side
self.sync_mkdir_on_opposite(name.clone());
} else {
// Do not synchronize, disable sync browsing and return
trace!(
@@ -183,23 +135,21 @@ impl FileTransferActivity {
self.umount_sync_browsing_mkdir_popup();
}
trace!("Entering on the other explorer directory {}", name);
// Enter directory
match destination {
SyncBrowsingDestination::ParentDir => match self.browser.tab() {
FileExplorerTab::HostBridge => self.remote_changedir(path.as_path(), true),
FileExplorerTab::Remote => self.host_bridge_changedir(path.as_path(), true),
_ => {}
},
SyncBrowsingDestination::Path(_) => match self.browser.tab() {
FileExplorerTab::HostBridge => self.remote_changedir(path.as_path(), true),
FileExplorerTab::Remote => self.host_bridge_changedir(path.as_path(), true),
_ => {}
},
SyncBrowsingDestination::PreviousDir => match self.browser.tab() {
FileExplorerTab::HostBridge => self.remote_changedir(path.as_path(), false),
FileExplorerTab::Remote => self.host_bridge_changedir(path.as_path(), false),
_ => {}
},
// Enter directory on the opposite side
let push = !matches!(destination, SyncBrowsingDestination::PreviousDir);
if is_local {
self.remote_changedir(path.as_path(), push);
} else {
self.local_changedir(path.as_path(), push);
}
}
/// Create a directory on the opposite side for sync browsing.
fn sync_mkdir_on_opposite(&mut self, name: String) {
if self.is_local_tab() {
self.action_remote_mkdir(name);
} else {
self.action_local_mkdir(name);
}
}
@@ -208,35 +158,34 @@ impl FileTransferActivity {
&mut self,
destination: &SyncBrowsingDestination,
) -> Option<PathBuf> {
match (destination, self.browser.tab()) {
// NOTE: tab and methods are switched on purpose
(SyncBrowsingDestination::ParentDir, FileExplorerTab::HostBridge) => {
self.remote().wrkdir.parent().map(|x| x.to_path_buf())
}
(SyncBrowsingDestination::ParentDir, FileExplorerTab::Remote) => {
self.host_bridge().wrkdir.parent().map(|x| x.to_path_buf())
}
(SyncBrowsingDestination::PreviousDir, FileExplorerTab::HostBridge) => {
if let Some(p) = self.remote_mut().popd() {
Some(p)
let is_local = self.is_local_tab();
match destination {
// NOTE: tab and methods are switched on purpose (we resolve from the opposite side)
SyncBrowsingDestination::ParentDir => {
if is_local {
self.remote().wrkdir.parent().map(Path::to_path_buf)
} else {
warn!("Cannot synchronize browsing: remote has no previous directory in stack");
None
self.host_bridge().wrkdir.parent().map(Path::to_path_buf)
}
}
(SyncBrowsingDestination::PreviousDir, FileExplorerTab::Remote) => {
if let Some(p) = self.host_bridge_mut().popd() {
SyncBrowsingDestination::PreviousDir => {
if is_local {
if let Some(p) = self.remote_mut().popd() {
Some(p)
} else {
warn!(
"Cannot synchronize browsing: remote has no previous directory in stack"
);
None
}
} else if let Some(p) = self.host_bridge_mut().popd() {
Some(p)
} else {
warn!("Cannot synchronize browsing: local has no previous directory in stack");
None
}
}
(SyncBrowsingDestination::Path(p), _) => Some(PathBuf::from(p.as_str())),
_ => {
warn!("Cannot synchronize browsing for current explorer");
None
}
SyncBrowsingDestination::Path(p) => Some(PathBuf::from(p.as_str())),
}
}
}
@@ -3,83 +3,12 @@ use remotefs::fs::UnixPex;
use super::{FileTransferActivity, LogLevel};
impl FileTransferActivity {
pub fn action_local_chmod(&mut self, mode: UnixPex) {
let files = self.get_local_selected_entries().get_files();
/// Change file mode for the currently selected entries via the active tab's pane.
pub(crate) fn action_chmod(&mut self, mode: UnixPex) {
let files = self.get_selected_entries().get_files();
for file in files {
if let Err(err) = self.host_bridge.chmod(file.path(), mode) {
self.log_and_alert(
LogLevel::Error,
format!(
"could not change mode for {}: {}",
file.path().display(),
err
),
);
return;
}
self.log(
LogLevel::Info,
format!("changed mode to {:#o} for {}", u32::from(mode), file.name()),
);
}
}
pub fn action_remote_chmod(&mut self, mode: UnixPex) {
let files = self.get_remote_selected_entries().get_files();
for file in files {
let mut metadata = file.metadata.clone();
metadata.mode = Some(mode);
if let Err(err) = self.client.setstat(file.path(), metadata) {
self.log_and_alert(
LogLevel::Error,
format!(
"could not change mode for {}: {}",
file.path().display(),
err
),
);
return;
}
self.log(
LogLevel::Info,
format!("changed mode to {:#o} for {}", u32::from(mode), file.name()),
);
}
}
pub fn action_find_local_chmod(&mut self, mode: UnixPex) {
let files = self.get_found_selected_entries().get_files();
for file in files {
if let Err(err) = self.host_bridge.chmod(file.path(), mode) {
self.log_and_alert(
LogLevel::Error,
format!(
"could not change mode for {}: {}",
file.path().display(),
err
),
);
return;
}
self.log(
LogLevel::Info,
format!("changed mode to {:#o} for {}", u32::from(mode), file.name()),
);
}
}
pub fn action_find_remote_chmod(&mut self, mode: UnixPex) {
let files = self.get_found_selected_entries().get_files();
for file in files {
let mut metadata = file.metadata.clone();
metadata.mode = Some(mode);
if let Err(err) = self.client.setstat(file.path(), metadata) {
if let Err(err) = self.browser.fs_pane_mut().fs.chmod(file.path(), mode) {
self.log_and_alert(
LogLevel::Error,
format!(
+44 -76
View File
@@ -1,61 +1,38 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// locals
use std::path::{Path, PathBuf};
use remotefs::{File, RemoteErrorType};
use remotefs::File;
use super::{FileTransferActivity, LogLevel, SelectedFile, TransferPayload};
impl FileTransferActivity {
/// Copy file on local
pub(crate) fn action_local_copy(&mut self, input: String) {
match self.get_local_selected_entries() {
/// Copy the currently selected file(s) via the active tab's pane.
pub(crate) fn action_copy(&mut self, input: String) {
match self.get_selected_entries() {
SelectedFile::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.local_copy_file(&entry, dest_path.as_path());
let dest_path = PathBuf::from(input);
self.copy_file(entry, dest_path.as_path());
}
SelectedFile::Many(entries) => {
// Iter files
for (entry, mut dest_path) in entries.into_iter() {
dest_path.push(entry.name());
self.local_copy_file(&entry, dest_path.as_path());
self.copy_file(entry, dest_path.as_path());
}
// clear selection
self.host_bridge_mut().clear_queue();
self.reload_host_bridge_filelist();
// clear selection and reload
self.browser.explorer_mut().clear_queue();
self.reload_browser_file_list();
}
SelectedFile::None => {}
}
}
/// Copy file on remote
pub(crate) fn action_remote_copy(&mut self, input: String) {
match self.get_remote_selected_entries() {
SelectedFile::One(entry) => {
let dest_path: PathBuf = PathBuf::from(input);
self.remote_copy_file(entry, dest_path.as_path());
}
SelectedFile::Many(entries) => {
// Iter files
for (entry, mut dest_path) in entries.into_iter() {
dest_path.push(entry.name());
self.remote_copy_file(entry, dest_path.as_path());
}
// clear selection
self.remote_mut().clear_queue();
self.reload_remote_filelist();
}
SelectedFile::None => {}
}
}
fn local_copy_file(&mut self, entry: &File, dest: &Path) {
match self.host_bridge.copy(entry, dest) {
fn copy_file(&mut self, entry: File, dest: &Path) {
match self.browser.fs_pane_mut().fs.copy(&entry, dest) {
Ok(_) => {
self.log(
LogLevel::Info,
@@ -66,45 +43,23 @@ impl FileTransferActivity {
),
);
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.path().display(),
dest.display(),
err
),
),
}
}
fn remote_copy_file(&mut self, entry: File, dest: &Path) {
match self.client.as_mut().copy(entry.path(), dest) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.path().display(),
dest.display()
),
);
}
Err(err) => match err.kind {
RemoteErrorType::UnsupportedFeature => {
// If copy is not supported, perform the tricky copy
Err(err) => {
// On remote tabs, fall back to tricky_copy (download + re-upload)
// when the protocol doesn't support server-side copy.
if !self.is_local_tab() {
let _ = self.tricky_copy(entry, dest);
} else {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.path().display(),
dest.display(),
err
),
);
}
_ => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.path().display(),
dest.display(),
err
),
),
},
}
}
}
@@ -138,7 +93,12 @@ impl FileTransferActivity {
return Err(err);
}
// Stat dir
let tempdir_entry = match self.host_bridge.stat(tempdir_path.as_path()) {
let tempdir_entry = match self
.browser
.local_pane_mut()
.fs
.stat(tempdir_path.as_path())
{
Ok(e) => e,
Err(err) => {
self.log_and_alert(
@@ -191,9 +151,17 @@ impl FileTransferActivity {
return Err(err);
}
// Get local fs entry
let tmpfile_entry = match self.host_bridge.stat(tmpfile.path()) {
let tmpfile_entry = match self.browser.local_pane_mut().fs.stat(tmpfile.path()) {
Ok(e) if e.is_file() => e,
Ok(_) => panic!("{} is not a file", tmpfile.path().display()),
Ok(_) => {
let msg = format!(
"Copy failed: \"{}\" is not a file",
tmpfile.path().display()
);
error!("{msg}");
self.log_and_alert(LogLevel::Error, msg.clone());
return Err(msg);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
@@ -1,6 +1,6 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// locals
use remotefs::File;
@@ -8,72 +8,28 @@ use remotefs::File;
use super::{FileTransferActivity, LogLevel, SelectedFile};
impl FileTransferActivity {
pub(crate) fn action_local_delete(&mut self) {
match self.get_local_selected_entries() {
/// Delete the currently selected file(s) via the active tab's pane.
pub(crate) fn action_delete(&mut self) {
match self.get_selected_entries() {
SelectedFile::One(entry) => {
// Delete file
self.local_remove_file(&entry);
self.remove_file(&entry);
}
SelectedFile::Many(entries) => {
// Iter files
for (entry, _) in entries.iter() {
// Delete file
self.local_remove_file(entry);
self.remove_file(entry);
}
// clear selection
self.host_bridge_mut().clear_queue();
self.reload_host_bridge_filelist();
// clear selection and reload
self.browser.explorer_mut().clear_queue();
self.reload_browser_file_list();
}
SelectedFile::None => {}
}
}
pub(crate) fn action_remote_delete(&mut self) {
match self.get_remote_selected_entries() {
SelectedFile::One(entry) => {
// Delete file
self.remote_remove_file(&entry);
}
SelectedFile::Many(entries) => {
// Iter files
for (entry, _) in entries.iter() {
// Delete file
self.remote_remove_file(entry);
}
// clear selection
self.remote_mut().clear_queue();
self.reload_remote_filelist();
}
SelectedFile::None => {}
}
}
pub(crate) fn local_remove_file(&mut self, entry: &File) {
match self.host_bridge.remove(entry) {
Ok(_) => {
// Log
self.log(
LogLevel::Info,
format!("Removed file \"{}\"", entry.path().display()),
);
}
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
entry.path().display(),
err
),
);
}
}
}
pub(crate) fn remote_remove_file(&mut self, entry: &File) {
match self.client.remove_dir_all(entry.path()) {
/// Remove a single file or directory via the active tab's pane.
pub(crate) fn remove_file(&mut self, entry: &File) {
match self.browser.fs_pane_mut().fs.remove(entry) {
Ok(_) => {
self.log(
LogLevel::Info,
+15 -10
View File
@@ -1,6 +1,6 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use std::fs::OpenOptions;
use std::io::Read;
@@ -9,7 +9,9 @@ use std::time::SystemTime;
use remotefs::File;
use remotefs::fs::Metadata;
use tuirealm::terminal::TerminalAdapter;
use super::super::ui_result;
use super::{FileTransferActivity, LogLevel, SelectedFile, TransferPayload};
impl FileTransferActivity {
@@ -28,7 +30,7 @@ impl FileTransferActivity {
format!("Opening file \"{}\"", entry.path().display()),
);
// Edit file
let res = match self.host_bridge.is_localhost() {
let res = match self.browser.local_pane().fs.is_localhost() {
true => self.edit_local_file(entry.path()).map(|_| ()),
false => self.edit_bridged_local_file(entry),
};
@@ -88,7 +90,7 @@ impl FileTransferActivity {
};
// open from host bridge
let mut reader = match self.host_bridge.open_file(entry.path()) {
let mut reader = match self.browser.local_pane_mut().fs.open_file(entry.path()) {
Ok(reader) => reader,
Err(err) => {
return Err(format!("Failed to open bridged entry: {err}"));
@@ -122,7 +124,7 @@ impl FileTransferActivity {
return Err(format!("Could not open file: {err}"));
}
};
let mut writer = match self.host_bridge.create_file(
let mut writer = match self.browser.local_pane_mut().fs.create_file(
entry.path(),
&Metadata {
size: new_file_size,
@@ -139,7 +141,9 @@ impl FileTransferActivity {
return Err(format!("Could not write file: {err}"));
}
self.host_bridge
self.browser
.local_pane_mut()
.fs
.finalize_write(writer)
.map_err(|err| format!("Could not write file: {err}"))?;
}
@@ -177,7 +181,7 @@ impl FileTransferActivity {
error!("Could not leave alternate screen: {}", err);
}
// Lock ports
assert!(self.app.lock_ports().is_ok());
ui_result(self.app.lock_ports());
// Get current file modification time
let prev_mtime = self.get_localhost_mtime(path)?;
// Open editor
@@ -205,7 +209,7 @@ impl FileTransferActivity {
error!("Could not clear screen screen: {}", err);
}
// Unlock ports
assert!(self.app.unlock_ports().is_ok());
ui_result(self.app.unlock_ports());
}
let after_mtime = self.get_localhost_mtime(path)?;
@@ -241,7 +245,8 @@ impl FileTransferActivity {
return Err(format!("Could not open file {file_name}: {err}"));
}
// Get current file modification time
let prev_mtime: SystemTime = match self.host_bridge.stat(tmpfile.as_path()) {
let prev_mtime: SystemTime = match self.browser.local_pane_mut().fs.stat(tmpfile.as_path())
{
Ok(e) => e.metadata().modified.unwrap_or(std::time::UNIX_EPOCH),
Err(err) => {
return Err(format!(
@@ -254,7 +259,7 @@ impl FileTransferActivity {
// Edit file
self.edit_local_file(tmpfile.as_path())?;
// Get local fs entry
let tmpfile_entry: File = match self.host_bridge.stat(tmpfile.as_path()) {
let tmpfile_entry: File = match self.browser.local_pane_mut().fs.stat(tmpfile.as_path()) {
Ok(e) => e,
Err(err) => {
return Err(format!(
@@ -280,7 +285,7 @@ impl FileTransferActivity {
),
);
// Get local fs entry
let tmpfile_entry = match self.host_bridge.stat(tmpfile.as_path()) {
let tmpfile_entry = match self.browser.local_pane_mut().fs.stat(tmpfile.as_path()) {
Ok(e) => e,
Err(err) => {
return Err(format!(
+19 -45
View File
@@ -1,6 +1,6 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use std::path::PathBuf;
use std::str::FromStr;
@@ -29,25 +29,22 @@ impl FromStr for Command {
Err("cd command requires a path".to_string())
}
}
Some("exit") | Some("logout") => Ok(Command::Exit),
Some(cmd) => Ok(Command::Exec(cmd.to_string())),
None => Err("".to_string()),
Some("exit" | "logout") => Ok(Command::Exit),
Some(_) => Ok(Command::Exec(s.to_string())),
None => Err(String::new()),
}
}
}
impl FileTransferActivity {
pub(crate) fn action_local_exec(&mut self, input: String) {
self.action_exec(false, input);
pub(crate) fn action_exec_cmd(&mut self, input: String) {
self.action_exec(input);
}
pub(crate) fn action_remote_exec(&mut self, input: String) {
self.action_exec(true, input);
}
fn action_exec(&mut self, remote: bool, cmd: String) {
fn action_exec(&mut self, cmd: String) {
if cmd.is_empty() {
self.print_terminal("".to_string());
self.print_terminal(String::new());
return;
}
let cmd = match Command::from_str(&cmd) {
@@ -61,10 +58,10 @@ impl FileTransferActivity {
match cmd {
Command::Cd(path) => {
self.action_exec_cd(remote, path);
self.action_exec_cd(path);
}
Command::Exec(executable) => {
self.action_exec_executable(remote, executable);
self.action_exec_executable(executable);
}
Command::Exit => {
self.action_exec_exit();
@@ -77,43 +74,20 @@ impl FileTransferActivity {
self.umount_exec();
}
fn action_exec_cd(&mut self, remote: bool, input: String) {
let new_dir = if remote {
let dir_path: PathBuf =
self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.remote_changedir(dir_path.as_path(), true);
dir_path
} else {
let dir_path: PathBuf =
self.host_bridge_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.host_bridge_changedir(dir_path.as_path(), true);
dir_path
};
fn action_exec_cd(&mut self, input: String) {
let dir_path = self.pane_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.pane_changedir(dir_path.as_path(), true);
self.update_browser_file_list();
// update prompt and print the new directory
self.update_terminal_prompt();
self.print_terminal(new_dir.display().to_string());
self.print_terminal(dir_path.display().to_string());
}
/// Execute a [`Command::Exec`] command
fn action_exec_executable(&mut self, remote: bool, cmd: String) {
let res = if remote {
self.client
.as_mut()
.exec(cmd.as_str())
.map(|(_, output)| output)
.map_err(|e| e.to_string())
} else {
self.host_bridge
.exec(cmd.as_str())
.map_err(|e| e.to_string())
};
match res {
/// Execute a command via the active tab's pane.
fn action_exec_executable(&mut self, cmd: String) {
match self.browser.fs_pane_mut().fs.exec(cmd.as_str()) {
Ok(output) => {
self.print_terminal(output);
}
@@ -122,7 +96,7 @@ impl FileTransferActivity {
LogLevel::Error,
format!("Could not execute command \"{cmd}\": {err}"),
);
self.print_terminal(err);
self.print_terminal(err.to_string());
}
}
}
@@ -1,37 +1,14 @@
use remotefs::File;
use super::{FileTransferActivity, LogLevel};
use crate::ui::activities::filetransfer::lib::browser::FileExplorerTab;
#[derive(Debug, Copy, Clone)]
enum Host {
HostBridge,
Remote,
}
impl FileTransferActivity {
/// Calculate and display the total size of the selected file(s) via the active tab's pane.
pub(crate) fn action_get_file_size(&mut self) {
// Get selected file
self.mount_blocking_wait("Getting total path size...");
let total_size = match self.browser.tab() {
FileExplorerTab::HostBridge => {
let files = self.get_local_selected_entries().get_files();
self.get_files_size(files, Host::HostBridge)
}
FileExplorerTab::Remote => {
let files = self.get_remote_selected_entries().get_files();
self.get_files_size(files, Host::Remote)
}
FileExplorerTab::FindHostBridge => {
let files = self.get_found_selected_entries().get_files();
self.get_files_size(files, Host::HostBridge)
}
FileExplorerTab::FindRemote => {
let files = self.get_found_selected_entries().get_files();
self.get_files_size(files, Host::Remote)
}
};
let files = self.get_selected_entries().get_files();
let total_size = self.get_files_size(files);
self.umount_wait();
self.mount_info(format!(
@@ -40,24 +17,19 @@ impl FileTransferActivity {
));
}
fn get_files_size(&mut self, files: Vec<File>, host: Host) -> u64 {
files.into_iter().map(|f| self.get_file_size(f, host)).sum()
fn get_files_size(&mut self, files: Vec<File>) -> u64 {
files.into_iter().map(|f| self.get_file_size(f)).sum()
}
fn get_file_size(&mut self, file: File, host: Host) -> u64 {
fn get_file_size(&mut self, file: File) -> u64 {
if let Some(symlink) = &file.metadata().symlink {
// stat
let stat_res = match host {
Host::HostBridge => self.host_bridge.stat(symlink).map_err(|e| e.to_string()),
Host::Remote => self.client.stat(symlink).map_err(|e| e.to_string()),
};
match stat_res {
match self.browser.fs_pane_mut().fs.stat(symlink) {
Ok(stat) => stat.metadata().size,
Err(err_msg) => {
Err(err) => {
self.log(
LogLevel::Error,
format!(
"Failed to stat symlink target {path}: {err_msg}",
"Failed to stat symlink target {path}: {err}",
path = symlink.display(),
),
);
@@ -65,22 +37,13 @@ impl FileTransferActivity {
}
}
} else if file.is_dir() {
// list and sum
let list_res = match host {
Host::HostBridge => self
.host_bridge
.list_dir(&file.path)
.map_err(|e| e.to_string()),
Host::Remote => self.client.list_dir(&file.path).map_err(|e| e.to_string()),
};
match list_res {
Ok(list) => list.into_iter().map(|f| self.get_file_size(f, host)).sum(),
Err(err_msg) => {
match self.browser.fs_pane_mut().fs.list_dir(&file.path) {
Ok(list) => list.into_iter().map(|f| self.get_file_size(f)).sum(),
Err(err) => {
self.log(
LogLevel::Error,
format!(
"Failed to list directory {path}: {err_msg}",
"Failed to list directory {path}: {err}",
path = file.path.display(),
),
);
+58 -93
View File
@@ -1,11 +1,11 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// locals
use std::path::PathBuf;
use super::super::browser::FileExplorerTab;
use super::change_dir::SyncBrowsingDestination;
use super::{File, FileTransferActivity, LogLevel, SelectedFile, TransferOpts, TransferPayload};
impl FileTransferActivity {
@@ -22,33 +22,27 @@ impl FileTransferActivity {
Some(p) => p.to_path_buf(),
}
};
// Change directory
match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
self.host_bridge_changedir(path.as_path(), true)
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.remote_changedir(path.as_path(), true)
}
// Change directory on the active tab's pane
self.pane_changedir(path.as_path(), true);
// Sync browsing: mirror the directory change on the opposite pane
if self.browser.sync_browsing {
let dir_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
self.synchronize_browsing(SyncBrowsingDestination::Path(dir_name));
}
}
}
pub(crate) fn action_find_transfer(&mut self, opts: TransferOpts) {
let wrkdir: PathBuf = match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
self.remote().wrkdir.clone()
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.host_bridge().wrkdir.clone()
}
};
let wrkdir: PathBuf = self.browser.opposite_pane().explorer.wrkdir.clone();
match self.get_found_selected_entries() {
SelectedFile::One(entry) => match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
SelectedFile::One(entry) => {
if self.is_local_tab() {
let file_to_check = Self::file_to_check(&entry, opts.save_as.as_ref());
if self.config().get_prompt_on_file_replace()
&& self.remote_file_exists(file_to_check.as_path())
&& self.file_exists(file_to_check.as_path(), false)
&& !self.should_replace_file(
opts.save_as.clone().unwrap_or_else(|| entry.name()),
)
@@ -66,11 +60,10 @@ impl FileTransferActivity {
format!("Could not upload file: {err}"),
);
}
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
} else {
let file_to_check = Self::file_to_check(&entry, opts.save_as.as_ref());
if self.config().get_prompt_on_file_replace()
&& self.host_bridge_file_exists(file_to_check.as_path())
&& self.file_exists(file_to_check.as_path(), true)
&& !self.should_replace_file(
opts.save_as.clone().unwrap_or_else(|| entry.name()),
)
@@ -89,7 +82,7 @@ impl FileTransferActivity {
);
}
}
},
}
SelectedFile::Many(entries) => {
// In case of selection: save multiple files in wrkdir/input
let mut dest_path: PathBuf = wrkdir;
@@ -97,58 +90,51 @@ impl FileTransferActivity {
dest_path.push(save_as);
}
// Iter files
match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
let super::save::TransferFilesWithOverwritesResult::FilesToTransfer(
entries,
) = self.get_files_to_transfer_with_overwrites(
if self.is_local_tab() {
let super::save::TransferFilesWithOverwritesResult::FilesToTransfer(entries) =
self.get_files_to_transfer_with_overwrites(
entries,
super::save::CheckFileExists::Remote,
)
else {
debug!("User cancelled file transfer due to overwrites");
return;
};
if let Err(err) = self.filetransfer_send(
TransferPayload::TransferQueue(entries),
dest_path.as_path(),
None,
) {
{
self.log_and_alert(
LogLevel::Error,
format!("Could not upload file: {err}"),
);
}
}
else {
debug!("User cancelled file transfer due to overwrites");
return;
};
if let Err(err) = self.filetransfer_send(
TransferPayload::TransferQueue(entries),
dest_path.as_path(),
None,
) {
self.log_and_alert(
LogLevel::Error,
format!("Could not upload file: {err}"),
);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
let super::save::TransferFilesWithOverwritesResult::FilesToTransfer(
entries,
) = self.get_files_to_transfer_with_overwrites(
} else {
let super::save::TransferFilesWithOverwritesResult::FilesToTransfer(entries) =
self.get_files_to_transfer_with_overwrites(
entries,
super::save::CheckFileExists::HostBridge,
)
else {
debug!("User cancelled file transfer due to overwrites");
return;
};
if let Err(err) = self.filetransfer_recv(
TransferPayload::TransferQueue(entries),
dest_path.as_path(),
None,
) {
self.log_and_alert(
LogLevel::Error,
format!("Could not download file: {err}"),
);
}
else {
debug!("User cancelled file transfer due to overwrites");
return;
};
if let Err(err) = self.filetransfer_recv(
TransferPayload::TransferQueue(entries),
dest_path.as_path(),
None,
) {
self.log_and_alert(
LogLevel::Error,
format!("Could not download file: {err}"),
);
}
// clear selection
if let Some(f) = self.found_mut() {
f.clear_queue();
self.update_find_list();
}
// clear selection
if let Some(f) = self.found_mut() {
f.clear_queue();
self.update_find_list();
}
}
}
@@ -159,14 +145,11 @@ impl FileTransferActivity {
pub(crate) fn action_find_delete(&mut self) {
match self.get_found_selected_entries() {
SelectedFile::One(entry) => {
// Delete file
self.remove_found_file(&entry);
self.remove_file(&entry);
}
SelectedFile::Many(entries) => {
// Iter files
for (entry, _) in entries.iter() {
// Delete file
self.remove_found_file(entry);
self.remove_file(entry);
}
// clear selection
@@ -179,17 +162,6 @@ impl FileTransferActivity {
}
}
fn remove_found_file(&mut self, entry: &File) {
match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
self.local_remove_file(entry);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.remote_remove_file(entry);
}
}
}
pub(crate) fn action_find_open(&mut self) {
match self.get_found_selected_entries() {
SelectedFile::One(entry) => {
@@ -235,13 +207,6 @@ impl FileTransferActivity {
}
fn open_found_file(&mut self, entry: &File, with: Option<&str>) {
match self.browser.tab() {
FileExplorerTab::FindHostBridge | FileExplorerTab::HostBridge => {
self.action_open_local_file(entry, with);
}
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
self.action_open_remote_file(entry, with);
}
}
self.open_file(entry, with);
}
}
@@ -1,6 +1,6 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
use super::FileTransferActivity;
+32 -35
View File
@@ -1,49 +1,46 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
// locals
use std::path::PathBuf;
use remotefs::fs::UnixPex;
use super::{FileTransferActivity, LogLevel};
impl FileTransferActivity {
pub(crate) fn action_local_mkdir(&mut self, input: String) {
match self
.host_bridge
.mkdir(PathBuf::from(input.as_str()).as_path())
{
Ok(_) => {
// Reload files
self.log(LogLevel::Info, format!("Created directory \"{input}\""));
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{input}\": {err}"),
);
}
/// Create a directory via the active tab's pane.
pub(crate) fn action_mkdir(&mut self, input: String) {
let path = PathBuf::from(input.as_str());
match self.browser.fs_pane_mut().fs.mkdir(path.as_path()) {
Ok(_) => self.log(LogLevel::Info, format!("Created directory \"{input}\"")),
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{input}\": {err}"),
),
}
}
pub(crate) fn action_remote_mkdir(&mut self, input: String) {
match self.client.as_mut().create_dir(
PathBuf::from(input.as_str()).as_path(),
UnixPex::from(0o755),
) {
Ok(_) => {
// Reload files
self.log(LogLevel::Info, format!("Created directory \"{input}\""));
}
Err(err) => {
// Report err
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{input}\": {err}"),
);
}
/// Create a directory on the local host (used by sync-browsing in change_dir).
pub(in crate::ui::activities::filetransfer) fn action_local_mkdir(&mut self, input: String) {
let path = PathBuf::from(input.as_str());
match self.browser.local_pane_mut().fs.mkdir(path.as_path()) {
Ok(_) => self.log(LogLevel::Info, format!("Created directory \"{input}\"")),
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{input}\": {err}"),
),
}
}
/// Create a directory on the remote host (used by sync-browsing in change_dir).
pub(in crate::ui::activities::filetransfer) fn action_remote_mkdir(&mut self, input: String) {
let path = PathBuf::from(input.as_str());
match self.browser.remote_pane_mut().fs.mkdir(path.as_path()) {
Ok(_) => self.log(LogLevel::Info, format!("Created directory \"{input}\"")),
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{input}\": {err}"),
),
}
}
}

Some files were not shown because too many files have changed in this diff Show More