Compare commits

..

83 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
veeso c32822037e ci: deploy site 2025-11-11 12:21:05 +01:00
veeso abb5c212c5 feat: Merge branch '0.19.0' 2025-11-11 12:19:21 +01:00
veeso e9b54a227b chore: CHANGELOG date 2025-11-11 09:42:21 +01:00
veeso befc32198a ci: debian fix
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
2025-11-10 17:25:29 +01:00
veeso 7e5103ff7e ci: Debian 2025-11-10 17:06:44 +01:00
veeso 2cb600083e docs: Release date 2025-11-10 16:44:24 +01:00
Christian Visintin 47d23673e6 ci: Build artifacts for Windows x86_64 and Ubuntu x86_64 (#368) 2025-11-10 16:43:25 +01:00
Christian Visintin a0b357cf8c feat: Added <CTRL+S> keybinding to get the total size of selected paths. (#367)
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
* feat: Added `<CTRL+S>` keybinding to get the total size of selected paths.

closes #297
2025-11-09 21:14:42 +01:00
Christian Visintin 75943f2b93 feat: Changed file overwrite behaviour (#366)
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Now the user can choose for each file whether to overwrite, skip or overwrite all/skip all.

closes #335
2025-11-09 19:00:17 +01:00
veeso 085ab721f9 build: remotefs-ssh 0.7.1
This version fixes compatibility with hosts which don't use bash/sh as the default shell.

closes #365
2025-11-09 17:38:50 +01:00
Christian Visintin f4156a5059 feat: Import bookmarks from ssh config with a CLI command (#364)
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
* feat: Import bookmarks from ssh config with a CLI command

Use import-ssh-hosts to import all the possible hosts by the configured ssh config or the default one on your machine

closes #331
2025-11-08 15:32:52 +01:00
Christian Visintin 4bebec369f fix: Issues with update checks (#363)
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Removed error popup message if failed to check for updates.
Prevent long timeouts when checking for updates if the network is down or the DNS is not working.

closes #354
2025-10-02 21:27:51 +02:00
Christian Visintin 05c8613279 fix: Report a message while calculating total size of files to transfer. (#362)
* fix: Report a message while calculating total size of files to transfer.

Currently, in case of huge transfers the app may look frozen while calculating the transfer size. We should at least report to the user we are actually doing something.

closes #361

* ci: windows runner
2025-10-02 20:58:26 +02:00
veeso 205d2813ad perf: Migrated to libssh.org on Linux and MacOS for better ssh agent support.
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
closes #337
2025-09-20 18:13:20 +02:00
veeso 86660a0cc9 fix: SMB support for MacOS with vendored build of libsmbclient.
closes #334
2025-09-20 18:07:26 +02:00
veeso 05830db206 docs: User manual and get started links
Install.sh / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Linux / build (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
2025-09-16 10:36:17 +02:00
veeso 3c79e812eb build: 0.19 deps 2025-09-06 16:40:01 +02:00
moshyfawn 0287e7706a fix: typo in file open error message (#349)
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-06-13 22:50:06 +02:00
213 changed files with 17699 additions and 14647 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: veeso
liberapay: veeso
+135 -9
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
env:
TERMSCP_VERSION: "0.18.0"
TERMSCP_VERSION: "1.0.0"
jobs:
build-binaries:
@@ -14,33 +14,159 @@ jobs:
platform:
- release_for: MacOS-x86_64
os: macos-latest
platform: macos
target: x86_64-apple-darwin
script: macos.sh
- release_for: MacOS-M1
- release_for: MacOS-aarch64
os: macos-latest
platform: macos
target: aarch64-apple-darwin
script: macos.sh
- release_for: Linux-x86_64
os: ubuntu-latest
platform: linux
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
targets: ${{ matrix.platform.target }}
- name: Build release
run: cargo build --release --target ${{ matrix.platform.target }}
- name: Prepare artifact files
- name: Install dependencies (Linux)
if: matrix.platform.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y \
make \
libgit2-dev \
build-essential \
pkg-config \
libbsd-dev \
libcap-dev \
libcups2-dev \
libgnutls28-dev \
libicu-dev \
libjansson-dev \
libkeyutils-dev \
libldap2-dev \
zlib1g-dev \
libpam0g-dev \
libacl1-dev \
libarchive-dev \
flex \
bison \
libntirpc-dev \
libtracker-sparql-3.0-dev \
libglib2.0-dev \
libdbus-1-dev \
libsasl2-dev \
libunistring-dev \
libdbus-1-dev \
cpanminus;
sudo cpanm Parse::Yapp::Driver
- name: Install dependencies (MacOS)
if: matrix.platform.platform == 'macos'
run: |
brew update
brew install \
bison \
cpanminus \
cups \
flex \
gettext \
gmp \
gnutls \
icu4c \
jansson \
libarchive \
libbsd \
libunistring \
libgit2 \
libtirpc \
openldap \
pkg-config \
zlib
brew link --force bison
brew link --force cups
brew link --force flex
brew link --force gettext
brew link --force gmp
brew link --force gnutls
brew link --force icu4c
brew link --force jansson
brew link --force libarchive
brew link --force libbsd
brew link --force libgit2
brew link --force libtirpc
brew link --force libunistring
brew link --force openldap
brew link --force zlib
cpanm Parse::Yapp::Driver
- name: Build release (MacOS Intel)
if: matrix.platform.target == 'x86_64-apple-darwin'
run: cargo build --release --no-default-features --features keyring --target ${{ matrix.platform.target }}
- name: Build release (others)
if: matrix.platform.target != 'x86_64-apple-darwin'
run: cargo build --release --features smb-vendored --target ${{ matrix.platform.target }}
- name: Build deb
if: matrix.platform.platform == 'linux'
run: |
cargo install cargo-deb
cargo deb --target ${{ matrix.platform.target }} --features smb-vendored
- name: Prepare artifact files (Posix)
if: matrix.platform.platform != 'windows'
run: |
mkdir -p .artifact
mv target/${{ matrix.platform.target }}/release/termscp .artifact/termscp
tar -czf .artifact/termscp-v${{ env.TERMSCP_VERSION }}-${{ matrix.platform.target }}.tar.gz -C .artifact termscp
ls -l .artifact/
- name: "Upload artifact"
- name: Upload artifact (Posix)
if: matrix.platform.platform != 'windows'
uses: actions/upload-artifact@v4
with:
if-no-files-found: error
retention-days: 1
name: termscp-${{ matrix.platform.target }}
path: .artifact/termscp-v${{ env.TERMSCP_VERSION }}-${{ matrix.platform.target }}.tar.gz
- name: Upload artifact (Windows)
if: matrix.platform.platform == 'windows'
uses: actions/upload-artifact@v4
with:
if-no-files-found: error
retention-days: 1
name: termscp-v${{ env.TERMSCP_VERSION }}-${{ matrix.platform.target }}
path: target/${{ matrix.platform.target }}/release/termscp.exe
- name: Upload artifact (Deb)
if: matrix.platform.platform == 'linux'
uses: actions/upload-artifact@v4
with:
if-no-files-found: error
retention-days: 1
name: termscp-${{ matrix.platform.target }}-deb
path: target/debian/termscp_${{ env.TERMSCP_VERSION }}-1_${{ matrix.platform.debian_suffix }}.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
+10 -2
View File
@@ -6,6 +6,7 @@ on:
- "*.md"
- "./site/**/*"
push:
branches: [ main ]
paths-ignore:
- "*.md"
- "./site/**/*"
@@ -14,14 +15,21 @@ 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
components: rustfmt, clippy
- name: Install dependencies
run: |
brew update
brew install \
pkg-config \
samba
brew link --force samba
- name: Build
run: cargo build
- name: Run tests
+2 -1
View File
@@ -6,7 +6,8 @@ on:
push:
branches: ["main"]
paths:
- "./site/**/*"
- ".github/workflows/website.yml"
- "site/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
+4 -3
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:
runs-on: windows-2019
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/
+235 -40
View File
@@ -1,49 +1,244 @@
# Changelog
- [Changelog](#changelog)
- [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
- [Issue 297](https://github.com/veeso/termscp/issues/297): Added `<CTRL+S>` keybinding to get the total size of selected paths.
- [Issue 331](https://github.com/veeso/termscp/issues/331): Added new `import-ssh-hosts` CLI subcommand to import all the hosts from the ssh config as bookmarks.
- [Issue 335](https://github.com/veeso/termscp/issues/335): Changed file overwrite behaviour
- Now the user can choose for each file whether to overwrite, skip or overwrite all/skip all.
- [Issue 354](https://github.com/veeso/termscp/issues/354):
- Removed error popup message if failed to check for updates.
- Prevent long timeouts when checking for updates if the network is down or the DNS is not working.
- [Issue 356](https://github.com/veeso/termscp/issues/356): Fixed SSH auth issue not trying with the password if any RSA key was found.
- [Issue 334](https://github.com/veeso/termscp/issues/334): SMB support for MacOS with vendored build of libsmbclient.
- [Issue 337](https://github.com/veeso/termscp/issues/337): Migrated to libssh.org on Linux and MacOS for better ssh agent support.
- [Issue 361](https://github.com/veeso/termscp/issues/361): Report a message while calculating total size of files to transfer.
## 0.18.0
Released on 10/06/2025
Released on 11/11/2025
- 🐚 An **Embedded shell for termscp**:
- [Issue 340](https://github.com/veeso/termscp/issues/340): Replaced the `Exec` popup with a **fully functional terminal emulator** embedded thanks to [A-Kenji's tui-term](https://github.com/a-kenji/tui-term).
+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
+2623 -1163
View File
File diff suppressed because it is too large Load Diff
+106 -86
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.18.0"
rust-version = "1.85.1"
rust-version = "1.89.0"
[package.metadata.rpm]
package = "termscp"
@@ -27,91 +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-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"] }
simplelog = "^0.12"
ssh2-config = "^0.5"
tempfile = "3"
thiserror = "2"
tokio = { version = "1.44", features = ["rt"] }
toml = "^0.8"
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(not(target_os = \"macos\"))".dependencies]
remotefs-smb = { version = "^0.3", optional = true }
[target."cfg(target_family = \"unix\")".dependencies]
remotefs-ftp = { version = "^0.2", features = ["vendored", "native-tls"] }
remotefs-ssh = { version = "^0.6", features = ["ssh2-vendored"] }
uzers = "0.12"
[target."cfg(target_family = \"windows\")".dependencies]
remotefs-ftp = { version = "^0.2", features = ["native-tls"] }
remotefs-ssh = { version = "^0.6" }
[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
+5 -5
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Website</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Installation</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Installation</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">User manual</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">User manual</a>
</p>
<p align="center">
@@ -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.18.0 10/06/2025</p>
<p align="center">Current version: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -191,7 +191,7 @@ Arch Linux users can install termscp from the official repositories.
pacman -S termscp
```
For more information or other platforms, please visit [termscp.veeso.dev](https://termscp.veeso.dev/#get-started) to view all installation methods.
For more information or other platforms, please visit [termscp.veeso.dev](https://termscp.veeso.dev/get-started.html) to view all installation methods.
⚠️ If you're looking on how to update termscp just run termscp from CLI with: `(sudo) termscp --update` ⚠️
@@ -237,7 +237,7 @@ You can make a donation with one of these platforms:
## User manual 📚
The user manual can be found on the [termscp's website](https://termscp.veeso.dev/#user-manual) or on [Github](docs/man.md).
The user manual can be found on the [termscp's website](https://termscp.veeso.dev/user-manual.html) or on [Github](docs/man.md).
---
+2 -2
View File
@@ -10,8 +10,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
posix: { target_family = "unix" },
win: { target_family = "windows" },
// exclusive features
smb: { all(feature = "smb", not( macos )) },
smb_unix: { all(unix, feature = "smb", not(macos)) },
smb: { feature = "smb" },
smb_unix: { all(unix, feature = "smb") },
smb_windows: { all(windows, feature = "smb") }
}
+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\
+6 -8
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Webseite</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Installation</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Installation</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">Benutzerhandbuch</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">Benutzerhandbuch</a>
</p>
<p align="center">
@@ -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.18.0 10/06/2025</p>
<p align="center">Aktuelle Version: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -186,19 +186,17 @@ Wenn Sie ein Windows-Benutzer sind, können Sie termscp mit [Chocolatey](https:/
choco install termscp
```
Für weitere Informationen oder andere Plattformen besuchen Sie bitte [termscp.veeso.dev](https://termscp.veeso.dev/termscp/#get-started), um alle Installationsmethoden anzuzeigen.
Für weitere Informationen oder andere Plattformen besuchen Sie bitte [termscp.veeso.dev](https://termscp.veeso.dev/termscp/get-started.html), um alle Installationsmethoden anzuzeigen.
⚠️ Wenn Sie wissen möchten, wie Sie termscp aktualisieren können, führen Sie einfach termscp über die CLI aus mit: `(sudo) termscp --update` ⚠️
### Softwareanforderungen ❗
- **Linux** Benutzer:
- libssh
- libdbus-1
- pkg-config
- libsmbclient
- **FreeBSD** Benutzer:
- libssh
- dbus
- pkgconf
- libsmbclient
@@ -234,7 +232,7 @@ Sie können mit einer dieser Plattformen spenden:
## User manual 📚
Das Benutzerhandbuch finden Sie auf der [termscp-Website](https://termscp.veeso.dev/termscp/#user-manual) oder auf [Github](man.md).
Das Benutzerhandbuch finden Sie auf der [termscp-Website](https://termscp.veeso.dev/termscp/user-manual.html) oder auf [Github](man.md).
---
@@ -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)
+23 -2
View File
@@ -10,6 +10,10 @@
- [Unterbefehle](#unterbefehle)
- [Ein Thema importieren](#ein-thema-importieren)
- [Neueste Version installieren](#neueste-version-installieren)
- [Unterbefehle](#unterbefehle-1)
- [Ein Theme importieren](#ein-theme-importieren)
- [Neueste Version installieren](#neueste-version-installieren-1)
- [SSH-Hosts importieren](#ssh-hosts-importieren)
- [S3-Verbindungsparameter](#s3-verbindungsparameter)
- [S3-Anmeldeinformationen 🦊](#s3-anmeldeinformationen-)
- [Dateiexplorer 📂](#dateiexplorer-)
@@ -29,9 +33,9 @@
- [AWS S3 Adressargument](#aws-s3-adressargument-1)
- [SMB Adressargument](#smb-adressargument-1)
- [Wie das Passwort bereitgestellt werden kann 🔐](#wie-das-passwort-bereitgestellt-werden-kann--1)
- [Unterbefehle](#unterbefehle-1)
- [Unterbefehle](#unterbefehle-2)
- [Ein Thema importieren](#ein-thema-importieren-1)
- [Neueste Version installieren](#neueste-version-installieren-1)
- [Neueste Version installieren](#neueste-version-installieren-2)
- [S3-Verbindungsparameter](#s3-verbindungsparameter-1)
- [S3-Anmeldeinformationen 🦊](#s3-anmeldeinformationen--1)
- [Dateiexplorer 📂](#dateiexplorer--1)
@@ -173,6 +177,22 @@ Führen Sie termscp als `termscp theme <thema-datei>` aus
Führen Sie termscp als `termscp update` aus
### Unterbefehle
#### Ein Theme importieren
Führen Sie termscp mit `termscp theme <theme-datei>` aus.
#### Neueste Version installieren
Führen Sie termscp mit `termscp update` aus.
#### SSH-Hosts importieren
Führen Sie termscp mit `termscp import-ssh-hosts [ssh-config-datei]` aus.
Importieren Sie alle Hosts aus der angegebenen SSH-Konfigurationsdatei (wenn keine angegeben ist, wird `~/.ssh/config` verwendet) als Lesezeichen in termscp. Identitätsdateien werden ebenfalls als SSH-Schlüssel in termscp importiert.
---
## S3-Verbindungsparameter
@@ -296,6 +316,7 @@ Diese Panels sind im Wesentlichen 3 (ja, tatsächlich drei):
| <CTRL+A> | Alle Dateien auswählen | |
| <ALT+A> | Alle Dateien abwählen | |
| <CTRL+C> | Dateiübertragungsvorgang abbrechen | |
| `<CTRL+S>` | Gesamte Größe des ausgewählten Pfads abrufen | Size |
| <CTRL+T> | Alle synchronisierten Pfade anzeigen | Track |
### Mit mehreren Dateien arbeiten 🥷
+6 -6
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Sitio Web</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Instalación</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Instalación</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">Manual de usuario</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">Manual de usuario</a>
</p>
<p align="center">
@@ -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.18.0 10/06/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"
@@ -186,7 +186,7 @@ mientras que si eres un usuario de Windows, puedes instalar termscp con [Chocola
choco install termscp
```
Para obtener más información u otras plataformas, visite [termscp.veeso.dev](https://termscp.veeso.dev/termscp/#get-started) para ver todos los métodos de instalación.
Para obtener más información u otras plataformas, visite [termscp.veeso.dev](https://termscp.veeso.dev/termscp/get-started.html) para ver todos los métodos de instalación.
⚠️ Si estás buscando cómo actualizar termscp, simplemente ejecute termscp desde CLI con:: `(sudo) termscp --update` ⚠️
@@ -232,7 +232,7 @@ Puedes hacer una donación con una de estas plataformas:
## Manual de usuario y documentación 📚
El manual del usuario se puede encontrar en el [sitio web de termscp](https://termscp.veeso.dev/termscp/#user-manual) o en [Github](man.md).
El manual del usuario se puede encontrar en el [sitio web de termscp](https://termscp.veeso.dev/termscp/user-manual.html) o en [Github](man.md).
---
@@ -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)
+31 -10
View File
@@ -8,6 +8,10 @@
- [Argumento de dirección de WebDAV](#argumento-de-dirección-de-webdav)
- [Argumento dirección por SMB](#argumento-dirección-por-smb)
- [Cómo se puede proporcionar la contraseña 🔐](#cómo-se-puede-proporcionar-la-contraseña-)
- [Subcomandos](#subcomandos)
- [Importar un tema](#importar-un-tema)
- [Instalar la versión más reciente](#instalar-la-versión-más-reciente)
- [Importar hosts SSH](#importar-hosts-ssh)
- [S3 parámetros de conexión](#s3-parámetros-de-conexión)
- [Credenciales de S3 🦊](#credenciales-de-s3-)
- [Explorador de archivos 📂](#explorador-de-archivos-)
@@ -153,6 +157,22 @@ La contraseña se puede proporcionar básicamente a través de 3 formas cuando s
- Con `sshpass`: puede proporcionar la contraseña a través de `sshpass`, p. ej. `sshpass -f ~/.ssh/topsecret.key termscp cvisintin@192.168.1.31`
- Se te pedirá que ingreses: si no utilizas ninguno de los métodos anteriores, se te pedirá la contraseña, como ocurre con las herramientas más clásicas como `scp`, `ssh`, etc.
### Subcomandos
#### Importar un tema
Ejecute termscp como `termscp theme <archivo-tema>`
#### Instalar la versión más reciente
Ejecute termscp como `termscp update`
#### Importar hosts SSH
Ejecute termscp como `termscp import-ssh-hosts [archivo-config-ssh]`
Importa todos los hosts del archivo de configuración SSH especificado (si no se proporciona, se usará `~/.ssh/config`) como marcadores en termscp. Los archivos de identidad también se importarán como claves SSH en termscp.
---
## S3 parámetros de conexión
@@ -231,25 +251,25 @@ Para cambiar de panel, debe escribir `<LEFT>` para mover el panel del explorador
| `<BACKTAB>` | Cambiar entre la pestaña de registro y el explorador | |
| `<A>` | Alternar archivos ocultos | All |
| `<B>` | Ordenar archivos por | Bubblesort? |
| `<C|F5>` | Copiar archivo / directorio | Copy |
| `<D|F7>` | Hacer directorio | Directory |
| `<E|F8|DEL>` | Eliminar archivo | Erase |
| `<C\|F5>` | Copiar archivo / directorio | Copy |
| `<D\|F7>` | Hacer directorio | Directory |
| `<E\|F8\|DEL>` | Eliminar archivo | Erase |
| `<F>` | Búsqueda de archivos | Find |
| `<G>` | Ir a la ruta proporcionada | Go to |
| `<H|F1>` | Mostrar ayuda | Help |
| `<H\|F1>` | Mostrar ayuda | Help |
| `<I>` | Mostrar información sobre el archivo | Info |
| `<K>` | Crear un enlace simbólico que apunte a la entrada seleccionada actualmente | symlinK |
| `<L>` | Recargar contenido del directorio / Borrar selección | List |
| `<M>` | Seleccione un archivo | Mark |
| `<N>` | Crear un nuevo archivo con el nombre proporcionado | New |
| `<O|F4>` | Editar archivo | Open |
| `<O\|F4>` | Editar archivo | Open |
| `<P>` | Open log panel | Panel |
| `<Q|F10>` | Salir de termscp | Quit |
| `<R|F6>` | Renombrar archivo | Rename |
| `<S|F2>` | Guardar archivo como... | Save |
| `<Q\|F10>` | Salir de termscp | Quit |
| `<R\|F6>` | Renombrar archivo | Rename |
| `<S\|F2>` | Guardar archivo como... | Save |
| `<T>` | Sincronizar los cambios en la ruta seleccionada con el control remoto | Track |
| `<U>` | Ir al directorio principal | Upper |
| `<V|F3>` | Abrir archivo con el programa predeterminado | View |
| `<V\|F3>` | Abrir archivo con el programa predeterminado | View |
| `<W>` | Abrir archivo con el programa proporcionado | With |
| `<X>` | Ejecutar un comando | eXecute |
| `<Y>` | Alternar navegación sincronizada | sYnc |
@@ -258,9 +278,10 @@ Para cambiar de panel, debe escribir `<LEFT>` para mover el panel del explorador
| `<CTRL+A>` | Seleccionar todos los archivos | |
| `<ALT+A>` | Deseleccionar todos los archivos | |
| `<CTRL+C>` | Abortar el proceso de transferencia de archivos | |
| `<CTRL+S>` | Obtener el tamaño total de la ruta seleccionada | Size |
| `<CTRL+T>` | Mostrar todas las rutas sincronizadas | Track |
### Trabajar con múltiples archivos 🥷
### Trabajar con múltiples archivos 🥷
Puedes optar por trabajar con varios archivos, usando estos controles:
+6 -6
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Site internet</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Installation</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Installation</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">Manuel de l'Utilisateur</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">Manuel de l'Utilisateur</a>
</p>
<p align="center">
@@ -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.18.0 10/06/2025</p>
<p align="center">Version actuelle: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -186,7 +186,7 @@ tandis que si tu es un utilisateur Windows, tu peux installer termscp avec [Choc
choco install termscp
```
Pour plus d'informations sur les autres méthodes d'installation, veuillez visiter [termscp.veeso.dev](https://termscp.veeso.dev/termscp/#get-started).
Pour plus d'informations sur les autres méthodes d'installation, veuillez visiter [termscp.veeso.dev](https://termscp.veeso.dev/termscp/get-started.html).
⚠️ Si tu cherche comme de mettre à jour termscp, tu dois exécuter cette commande dans le terminal: `(sudo) termscp --update` ⚠️
@@ -232,7 +232,7 @@ Tu peux faire un don avec l'une de ces plateformes:
## Manuel d'utilisateur et Documentation 📚
Le manuel d'utilisateur peut être trouvé sur le [site de termscp](https://termscp.veeso.dev/termscp/#user-manual) ou sur [Github](man.md).
Le manuel d'utilisateur peut être trouvé sur le [site de termscp](https://termscp.veeso.dev/termscp/user-manual.html) ou sur [Github](man.md).
La documentation peut être trouvé sur Rust Docs <https://docs.rs/termscp>
@@ -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)
+31 -11
View File
@@ -8,6 +8,10 @@
- [Argument d'adresse WebDAV](#argument-dadresse-webdav)
- [Argument d'adresse SMB](#argument-dadresse-smb)
- [Comment le mot de passe peut être fourni 🔐](#comment-le-mot-de-passe-peut-être-fourni-)
- [Sous-commandes](#sous-commandes)
- [Importer un thème](#importer-un-thème)
- [Installer la dernière version](#installer-la-dernière-version)
- [Importer des hôtes SSH](#importer-des-hôtes-ssh)
- [S3 paramètres de connexion](#s3-paramètres-de-connexion)
- [Identifiants S3 🦊](#identifiants-s3-)
- [Explorateur de fichiers 📂](#explorateur-de-fichiers-)
@@ -142,7 +146,6 @@ syntaxe **Other systems**:
smb://[username@]<server-name>[:port]/<share>[/path/.../]
```
#### Comment le mot de passe peut être fourni 🔐
Vous avez probablement remarqué que, lorsque vous fournissez l'adresse comme argument, il n'y a aucun moyen de fournir le mot de passe.
@@ -152,6 +155,22 @@ Le mot de passe peut être fourni de 3 manières lorsque l'argument d'adresse es
- Avec `sshpass`: vous pouvez fournir un mot de passe via `sshpass`, par ex. `sshpass -f ~/.ssh/topsecret.key termscp cvisintin@192.168.1.31`
- Il vous sera demandé : si vous n'utilisez aucune des méthodes précédentes, le mot de passe vous sera demandé, comme c'est le cas avec les outils plus classiques tels que `scp`, `ssh`, etc.
### Sous-commandes
#### Importer un thème
Exécutez termscp avec `termscp theme <fichier-thème>`
#### Installer la dernière version
Exécutez termscp avec `termscp update`
#### Importer des hôtes SSH
Exécutez termscp avec `termscp import-ssh-hosts [fichier-config-ssh]`
Importez tous les hôtes du fichier de configuration SSH spécifié (si non fourni, `~/.ssh/config` sera utilisé) comme favoris dans termscp. Les fichiers d'identité seront également importés comme clés SSH dans termscp.
---
## S3 paramètres de connexion
@@ -230,25 +249,25 @@ Pour changer de panneau, vous devez taper `<LEFT>` pour déplacer le panneau de
| `<BACKTAB>` | Basculer entre l'onglet journal et l'explorateur | |
| `<A>` | Basculer les fichiers cachés | All |
| `<B>` | Trier les fichiers par | Bubblesort? |
| `<C|F5>` | Copier le fichier/répertoire | Copy |
| `<D|F7>` | Créer un dossier | Directory |
| `<E|F8|DEL>` | Supprimer le fichier (Identique à `DEL`) | Erase |
| `<C\|F5>` | Copier le fichier/répertoire | Copy |
| `<D\|F7>` | Créer un dossier | Directory |
| `<E\|F8\|DEL>` | Supprimer le fichier (Identique à `DEL`) | Erase |
| `<F>` | Rechercher des fichiers | Find |
| `<G>` | Aller au chemin fourni | Go to |
| `<H|F1>` | Afficher l'aide | Help |
| `<H\|F1>` | Afficher l'aide | Help |
| `<I>` | Afficher les informations sur le fichier ou le dossier sélectionné | Info |
| `<K>` | Créer un lien symbolique pointant vers l'entrée actuellement sélectionnée | symlinK |
| `<L>` | Recharger le contenu du répertoire actuel / Effacer la sélection | List |
| `<M>` | Sélectionner un fichier | Mark |
| `<N>` | Créer un nouveau fichier avec le nom fourni | New |
| `<O|F4>` | Modifier le fichier | Open |
| `<O\|F4>` | Modifier le fichier | Open |
| `<P>` | Ouvre le panel de journals | Panel |
| `<Q|F10>` | Quitter termscp | Quit |
| `<R|F6>` | Renommer le fichier | Rename |
| `<S|F2>` | Enregistrer le fichier sous... | Save |
| `<Q\|F10>` | Quitter termscp | Quit |
| `<R\|F6>` | Renommer le fichier | Rename |
| `<S\|F2>` | Enregistrer le fichier sous... | Save |
| `<T>` | Synchroniser les modifications apportées au chemin sélectionné | Track |
| `<U>` | Aller dans le répertoire parent | Upper |
| `<V|F3>` | Ouvrir le fichier avec le programme défaut pour le type de fichier | View |
| `<V\|F3>` | Ouvrir le fichier avec le programme défaut pour le type de fichier | View |
| `<W>` | Ouvrir le fichier avec le programme spécifié | With |
| `<X>` | Exécuter une commande | eXecute |
| `<Y>` | Basculer la navigation synchronisée | sYnc |
@@ -257,9 +276,10 @@ Pour changer de panneau, vous devez taper `<LEFT>` pour déplacer le panneau de
| `<CTRL+A>` | Sélectionner tous les fichiers | |
| `<ALT+A>` | Desélectionner tous les fichiers | |
| `<CTRL+C>` | Abandonner le processus de transfert de fichiers | |
| `<CTRL+S>` | Obtenir la taille totale du chemin sélectionné | Size |
| `<CTRL+T>` | Afficher tous les chemins synchronisés | Track |
### Travailler sur plusieurs fichiers 🥷
### Travailler sur plusieurs fichiers 🥷
Vous pouvez choisir de travailler sur plusieurs fichiers avec ces simples commandes :
+6 -6
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Sito</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Installazione</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Installazione</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">Manuale utente</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">Manuale utente</a>
</p>
<p align="center">
@@ -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.18.0 10/06/2025</p>
<p align="center">Versione corrente: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -186,7 +186,7 @@ mentre se sei un utente Windows, puoi installare termscp con [Chocolatey](https:
choco install termscp
```
Per ulteriori informazioni sui metodi di installazione su altre piattaforme, visita [termscp.veeso.dev](https://termscp.veeso.dev/termscp/#get-started).
Per ulteriori informazioni sui metodi di installazione su altre piattaforme, visita [termscp.veeso.dev](https://termscp.veeso.dev/termscp/get-started.html).
⚠️ Se stavi cercando come aggiornare la tua versione di termscp, puoi semplicemente lanciare termscp con questi argomenti: `(sudo) termscp --update` ⚠️
@@ -232,7 +232,7 @@ Puoi fare una donazione tramite una di queste piattaforme:
## Manuale utente 📚
Il manuale utente lo puoi trovare sul [sito di termscp](https://termscp.veeso.dev/termscp/#user-manual) o su [Github](man.md).
Il manuale utente lo puoi trovare sul [sito di termscp](https://termscp.veeso.dev/termscp/user-manual.html) o su [Github](man.md).
---
@@ -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)
+30 -10
View File
@@ -8,6 +8,10 @@
- [Argomento indirizzo per WebDAV](#argomento-indirizzo-per-webdav)
- [Indirizzo SMB](#indirizzo-smb)
- [Come fornire la password 🔐](#come-fornire-la-password-)
- [Sottocomandi](#sottocomandi)
- [Importare un tema](#importare-un-tema)
- [Installare lultima versione](#installare-lultima-versione)
- [Importare host SSH](#importare-host-ssh)
- [Parametri di connessione S3](#parametri-di-connessione-s3)
- [Credenziali S3 🦊](#credenziali-s3-)
- [File explorer 📂](#file-explorer-)
@@ -140,7 +144,6 @@ SMB ha una sintassi differente rispetto agli altri protocolli e cambia in base a
smb://[username@]<server-name>[:port]/<share>[/path/.../]
```
#### Come fornire la password 🔐
Quando si usa l'argomento indirizzo non è possibile fornire la password direttamente nell'argomento, esistono però altri metodi per farlo:
@@ -149,6 +152,22 @@ Quando si usa l'argomento indirizzo non è possibile fornire la password diretta
- Tramite `sshpass`: puoi fornire la password tramite l'applicazione GNU/Linux sshpass `sshpass -f ~/.ssh/topsecret.key termscp cvisintin@192.168.1.31`
- Forniscila quando richiesta: se non la fornisci tramite nessun metodo precedente, alla connessione ti verrà richiesto di fornirla in un prompt che la oscurerà (come avviene con sudo tipo).
### Sottocomandi
#### Importare un tema
Esegui termscp come `termscp theme <file-tema>`
#### Installare lultima versione
Esegui termscp come `termscp update`
#### Importare host SSH
Esegui termscp come `termscp import-ssh-hosts [file-config-ssh]`
Importa tutti gli host dal file di configurazione SSH specificato (se non fornito, verrà usato `~/.ssh/config`) come segnalibri in termscp. I file di identità verranno importati come chiavi SSH in termscp.
---
## Parametri di connessione S3
@@ -226,25 +245,25 @@ Per cambiare pannello ti puoi muovere con le frecce, `<LEFT>` per andare sul pan
| `<BACKTAB>` | Cambia tra explorer e pannello di log | |
| `<A>` | Mostra/nascondi file nascosti | All |
| `<B>` | Ordina file per | Bubblesort? |
| `<C|F5>` | Copia file/directory | Copy |
| `<D|F7>` | Crea directory | Directory |
| `<E|F8|DEL>` | Elimina file | Erase |
| `<C\|F5>` | Copia file/directory | Copy |
| `<D\|F7>` | Crea directory | Directory |
| `<E\|F8\|DEL>` | Elimina file | Erase |
| `<F>` | Cerca file (wild match supportato) | Find |
| `<G>` | Vai al percorso indicato | Go to |
| `<H|F1>` | Mostra help | Help |
| `<H\|F1>` | Mostra help | Help |
| `<I>` | Mostra informazioni per il file selezionato | Info |
| `<K>` | Crea un link simbolico che punta al file selezionato | symlinK |
| `<L>` | Ricarica posizione corrente / pulisci selezione file | List |
| `<M>` | Seleziona file | Mark |
| `<N>` | Crea nuovo file con il nome fornito | New |
| `<O|F4>` | Modifica file; Vedi text editor | Open |
| `<O\|F4>` | Modifica file; Vedi text editor | Open |
| `<P>` | Apri pannello log | Panel |
| `<Q|F10>` | Termina termscp | Quit |
| `<R|F6>` | Rinomina file | Rename |
| `<S|F2>` | Salva file con nome | Save |
| `<Q\|F10>` | Termina termscp | Quit |
| `<R\|F6>` | Rinomina file | Rename |
| `<S\|F2>` | Salva file con nome | Save |
| `<T>` | Sincronizza il percorso locale con l'host remoto | Track |
| `<U>` | Vai alla directory padre | Upper |
| `<V|F3>` | Apri il file con il programma definito dal sistema | View |
| `<V\|F3>` | Apri il file con il programma definito dal sistema | View |
| `<W>` | Apri il file con il programma specificato | With |
| `<X>` | Esegui comando shell | eXecute |
| `<Y>` | Abilita/disabilita Sync-Browsing | sYnc |
@@ -253,6 +272,7 @@ Per cambiare pannello ti puoi muovere con le frecce, `<LEFT>` per andare sul pan
| `<CTRL+A>` | Seleziona tutti i file | |
| `<ALT+A>` | Deseleziona tutti i file | |
| `<CTRL+C>` | Annulla trasferimento file | |
| `<CTRL+S>` | Ottieni la dimensione totale del percorso selezionato | Size |
| `<CTRL+T>` | Visualizza tutti i percorsi sincronizzati | Track |
### Lavora con più file 🥷
+8
View File
@@ -11,6 +11,7 @@
- [Subcommands](#subcommands)
- [Import a theme](#import-a-theme)
- [Install latest version](#install-latest-version)
- [Import ssh hosts](#import-ssh-hosts)
- [S3 connection parameters](#s3-connection-parameters)
- [S3 credentials 🦊](#s3-credentials-)
- [File explorer 📂](#file-explorer-)
@@ -166,6 +167,12 @@ Run termscp as `termscp theme <theme-file>`
Run termscp as `termscp update`
#### Import ssh hosts
Run termscp as `termscp import-ssh-hosts [ssh-config-file]`
Import all the hosts from the specified ssh config file (if not provided, `~/.ssh/config` will be used) as bookmarks in termscp. Identity files will be imported as ssh keys in termscp too.
---
## S3 connection parameters
@@ -271,6 +278,7 @@ In order to change panel you need to type `<LEFT>` to move the remote explorer p
| `<CTRL+A>` | Select all files | |
| `<ALT+A>` | Deselect all files | |
| `<CTRL+C>` | Abort file transfer process | |
| `<CTRL+S>` | Get total size of the selected path | Size |
| `<CTRL+T>` | Show all synchronized paths | Track |
### Work on multiple files 🥷
+5 -5
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">Website</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">Instalação</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">Instalação</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">Manual do usuário</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">Manual do usuário</a>
</p>
<p align="center">
@@ -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.18.0 10/06/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"
@@ -195,7 +195,7 @@ Usuários do Arch Linux podem instalar o termscp pelos repositórios oficiais.
pacman -S termscp
```
Para mais informações ou outras plataformas, visite [termscp.veeso.dev](https://termscp.veeso.dev/#get-started) para ver todos os métodos de instalação.
Para mais informações ou outras plataformas, visite [termscp.veeso.dev](https://termscp.veeso.dev/get-started.html) para ver todos os métodos de instalação.
⚠️ Se você quer saber como atualizar o termscp, basta executar o termscp a partir do CLI com: `(sudo) termscp --update` ⚠️
@@ -241,7 +241,7 @@ Você pode fazer uma doação por meio de uma dessas plataformas:
## Manual do Usuário 📚
O manual do usuário pode ser encontrado no [site do termscp](https://termscp.veeso.dev/#user-manual) ou no [Github](docs/man.md).
O manual do usuário pode ser encontrado no [site do termscp](https://termscp.veeso.dev/user-manual.html) ou no [Github](docs/man.md).
---
+8
View File
@@ -11,6 +11,7 @@
- [Subcomandos](#subcomandos)
- [Importar um Tema](#importar-um-tema)
- [Instalar a Última Versão](#instalar-a-última-versão)
- [Importar hosts SSH](#importar-hosts-ssh)
- [Parâmetros de Conexão do S3](#parâmetros-de-conexão-do-s3)
- [Credenciais do S3 🦊](#credenciais-do-s3-)
- [Explorador de Arquivos 📂](#explorador-de-arquivos-)
@@ -164,6 +165,12 @@ Execute o termscp como `termscp theme <theme-file>`
Execute o termscp como `termscp update`
#### Importar hosts SSH
Execute o termscp como `termscp import-ssh-hosts [arquivo-config-ssh]`
Importe todos os hosts do arquivo de configuração SSH especificado (se não for fornecido, `~/.ssh/config` será usado) como favoritos no termscp. Os arquivos de identidade também serão importados como chaves SSH no termscp.
---
## Parâmetros de Conexão do S3
@@ -271,6 +278,7 @@ Para trocar de painel, você precisa pressionar `<LEFT>` para mover para o paine
| `<CTRL+A>` | Selecionar todos os arquivos | |
| `<ALT+A>` | Deselecionar todos os arquivos | |
| `<CTRL+C>` | Abortir processo de transferência de arquivo | |
| `<CTRL+S>` | Obter o tamanho total do caminho selecionado | | Size |
| `<CTRL+T>` | Mostrar todos os caminhos sincronizados | Track |
### Trabalhar com múltiplos arquivos 🥷
+6 -8
View File
@@ -8,9 +8,9 @@
<p align="center">
<a href="https://termscp.veeso.dev" target="_blank">网站</a>
·
<a href="https://termscp.veeso.dev/#get-started" target="_blank">安装</a>
<a href="https://termscp.veeso.dev/get-started.html" target="_blank">安装</a>
·
<a href="https://termscp.veeso.dev/#user-manual" target="_blank">用户手册</a>
<a href="https://termscp.veeso.dev/user-manual.html" target="_blank">用户手册</a>
</p>
<p align="center">
@@ -71,7 +71,7 @@
</p>
<p align="center">由 <a href="https://veeso.me/" target="_blank">@veeso</a> 开发</p>
<p align="center">当前版本: 0.18.0 10/06/2025</p>
<p align="center">当前版本: 1.0.0 2026-04-18</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -189,19 +189,17 @@ curl -sSLf http://get-termscp.veeso.dev | sh
choco install termscp
```
如需更多信息或其他的平台支持,请访问 [termscp.veeso.dev](https://termscp.veeso.dev/termscp/#get-started) 查看所有安装方法。
如需更多信息或其他的平台支持,请访问 [termscp.veeso.dev](https://termscp.veeso.dev/termscp/get-started.html) 查看所有安装方法。
⚠️ 如果您正在寻找如何更新 termscp 只需从 CLI 运行 termscp `(sudo) termscp --update` ⚠️
### 依赖 ❗
- **Linux** 用户:
- libssh
- libdbus-1
- pkg-config
- libsmbclient
- **FreeBSD** 用户:
- libssh
- dbus
- pkgconf
- libsmbclient
@@ -238,7 +236,7 @@ choco install termscp
## 用户手册和文档 📚
用户手册可以在[termscp的网站](https://termscp.veeso.dev/termscp/#user-manual)或者在[Github](man.md)上找到。
用户手册可以在[termscp的网站](https://termscp.veeso.dev/termscp/user-manual.html)或者在[Github](man.md)上找到。
---
@@ -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)
+29 -9
View File
@@ -8,6 +8,10 @@
- [WebDAV 地址参数](#webdav-地址参数)
- [SMB 地址参数](#smb-地址参数)
- [如何输入密码](#如何输入密码)
- [子命令](#子命令)
- [导入主题](#导入主题)
- [安装最新版本](#安装最新版本)
- [导入 SSH 主机](#导入-ssh-主机)
- [S3 连接参数](#s3-连接参数)
- [Aws S3 凭证](#aws-s3-凭证)
- [文件浏览](#文件浏览)
@@ -149,6 +153,21 @@ smb://[username@]<server-name>[:port]/<share>[/path/.../]
- 通过 `sshpass`: 你可以通过 `sshpass` 传入密码, 例如: `sshpass -f ~/.ssh/topsecret.key termscp cvisintin@192.168.1.31`
- 提示输入密码:如果你不使用前面的任何方法,你会被提示输入密码,就像 `scp``ssh` 等比较经典的工具上一样。
### 子命令
#### 导入主题
以 termscp theme <theme-file> 的方式运行 termscp。
#### 安装最新版本
以 termscp update 的方式运行 termscp。
#### 导入 SSH 主机
`termscp import-ssh-hosts [ssh-config-file]` 的方式运行 termscp。
从指定的 SSH 配置文件中导入所有主机(如果未提供,则使用 `~/.ssh/config`)作为 termscp 中的书签。身份文件也会作为 SSH 密钥导入到 termscp 中。
---
## S3 连接参数
@@ -226,25 +245,25 @@ termscp中的文件资源管理器是指你与远程建立连接后可以看到
| `<BACKTAB>` | 在日志面板和管理器面板之间切换 | |
| `<A>` | 是否显示隐藏文件 | All |
| `<B>` | 按..排序 | Bubblesort? |
| `<C|F5>` | 复制文件(夹) | Copy |
| `<D|F7>` | 创建文件夹 | Directory |
| `<E|F8|DEL>` | 删除文件 | Erase |
| `<C\|F5>` | 复制文件(夹) | Copy |
| `<D\|F7>` | 创建文件夹 | Directory |
| `<E\|F8\|DEL>` | 删除文件 | Erase |
| `<F>` | 文件搜索 (支持通配符) | Find |
| `<G>` | 跳转到指定路径 | Go to |
| `<H|F1>` | 显示帮助 | Help |
| `<H\|F1>` | 显示帮助 | Help |
| `<I>` | 显示选中文件(夹)信息 | Info |
| `<K>` | 创建指向当前选定条目的符号链接 | symlinK |
| `<L>` | 刷新当前目录列表 / 清除选中状态 | List |
| `<M>` | 选中文件 | Mark |
| `<N>` | 使用键入的名称新建文件 | New |
| `<O|F4>` | 编辑文件;参考文本编辑器文档 | Open |
| `<O\|F4>` | 编辑文件;参考文本编辑器文档 | Open |
| `<P>` | 打开日志面板 | Panel |
| `<Q|F10>` | 退出termscp | Quit |
| `<R|F7>` | 重命名文件 | Rename |
| `<S|F2>` | 另存为... | Save |
| `<Q\|F10>` | 退出termscp | Quit |
| `<R\|F7>` | 重命名文件 | Rename |
| `<S\|F2>` | 另存为... | Save |
| `<T>` | 显示所有同步路径 | Track |
| `<U>` | 进入上层目录 | Upper |
| `<V|F3>` | 使用默认方式打开文件 | View |
| `<V\|F3>` | 使用默认方式打开文件 | View |
| `<W>` | 使用指定程序打开文件 | With |
| `<X>` | 运行命令 | eXecute |
| `<Y>` | 是否开启同步浏览 | sYnc |
@@ -253,6 +272,7 @@ termscp中的文件资源管理器是指你与远程建立连接后可以看到
| `<CTRL+A>` | 选中所有文件 | |
| `<ALT+A>` | 取消选择所有文件 | |
| `<CTRL+C>` | 终止文件传输 | |
| `<CTRL+S>` | 获取所选路径的总大小 | Size |
| `<CTRL+T>` | 显示所有同步路径 | Track |
### 操作多个文件 🥷
+53 -31
View File
@@ -8,10 +8,10 @@
# -f, -y, --force, --yes
# Skip the confirmation prompt during installation
TERMSCP_VERSION="0.18.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"
@@ -451,7 +473,7 @@ case $PLATFORM in
esac
completed "Congratulations! Termscp has successfully been installed on your system!"
info "If you're a new user, you might be interested in reading the user manual <https://termscp.veeso.dev/#user-manual>"
info "If you're a new user, you might be interested in reading the user manual <https://termscp.veeso.dev/user-manual.html>"
info "While if you've just updated your termscp version, you can find the changelog at this link <https://termscp.veeso.dev/#changelog>"
info "Remember that if you encounter any issue, you can report them on Github <https://github.com/veeso/termscp/issues/new>"
info "Feel free to open an issue also if you have an idea which could improve the project"
+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.18.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.18.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.18.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>
+2 -2
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.18.0 is NOW out! Download it from",
"versionAlert": "termscp 1.0.0 is NOW out! Download it from",
"here": "here",
"features": {
"handy": {
@@ -112,4 +112,4 @@
"then": "Once started, you will be prompted whether to install or not the update. Confirm the installation and ta-dah, the new version of termscp should now be available on your machine"
}
}
}
}
+2 -2
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.18.0 ya está disponible! Descárgalo desde",
"versionAlert": "termscp 1.0.0 ya está disponible! Descárgalo desde",
"here": "aquì",
"features": {
"handy": {
@@ -112,4 +112,4 @@
"then": "Una vez iniciado, se le preguntará si desea instalar o no la actualización. Confirme la instalación y ta-dah, la nueva versión de termscp ahora debería estar disponible en su máquina"
}
}
}
}
+2 -2
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.18.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": {
@@ -112,4 +112,4 @@
"then": "Une fois démarré, vous serez invité à installer ou non la mise à jour. Confirmez l'installation et ta-dah, la nouvelle version de termscp devrait maintenant être disponible sur votre machine"
}
}
}
}
+2 -2
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.18.0 è ORA disponbile! Scaricalo da",
"versionAlert": "termscp 1.0.0 è ORA disponbile! Scaricalo da",
"here": "qui",
"features": {
"handy": {
@@ -112,4 +112,4 @@
"then": "Una volta lanciato, se c'è un aggiornamento disponibile ti chiederà se procedere. Conferma e a questo punto dovrebbe installarlo. Se tutto è andato a buon fine, riavviando termscp dovrebbe essere l'ultima versione."
}
}
}
}
+2 -2
View File
@@ -12,7 +12,7 @@
"intro": {
"caption": "功能丰富的终端 UI 文件传输和浏览器,支持 SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "开始 →",
"versionAlert": "termscp 0.18.0 现已发布! 从下载",
"versionAlert": "termscp 1.0.0 现已发布! 从下载",
"here": "这里",
"features": {
"handy": {
@@ -112,4 +112,4 @@
"then": "启动后,系统将提示您是否安装更新。 确认安装和 ta-dah,新版本的termscp 现在应该可以在你的机器上使用了"
}
}
}
}
+38 -50
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
@@ -448,35 +464,7 @@ impl ActivityManager {
// -- misc
fn init_bookmarks_client(keyring: bool) -> Result<Option<BookmarksClient>, String> {
// Get config dir
match environment::init_config_dir() {
Ok(path) => {
// If some configure client, otherwise do nothing; don't bother users telling them that bookmarks are not supported on their system.
if let Some(config_dir_path) = path {
let bookmarks_file: PathBuf =
environment::get_bookmarks_paths(config_dir_path.as_path());
// Initialize client
BookmarksClient::new(
bookmarks_file.as_path(),
config_dir_path.as_path(),
16,
keyring,
)
.map(Option::Some)
.map_err(|e| {
format!(
"Could not initialize bookmarks (at \"{}\", \"{}\"): {}",
bookmarks_file.display(),
config_dir_path.display(),
e
)
})
} else {
Ok(None)
}
}
Err(err) => Err(err),
}
crate::support::bookmarks_client(keyring)
}
/// Initialize configuration client
+24 -2
View File
@@ -15,6 +15,9 @@ use crate::system::logging::LogLevel;
pub enum Task {
Activity(NextActivity),
/// Import ssh hosts from the specified ssh config file, or from the default location
/// and save them as bookmarks.
ImportSshHosts(Option<PathBuf>),
ImportTheme(PathBuf),
InstallUpdate,
Version,
@@ -72,7 +75,8 @@ pub struct Args {
#[argh(subcommand)]
pub enum ArgsSubcommands {
Config(ConfigArgs),
LoadTheme(LoadThemeArgs),
ImportSshHosts(ImportSshHostsArgs),
ImportTheme(ImportThemeArgs),
Update(UpdateArgs),
}
@@ -86,10 +90,20 @@ pub struct ConfigArgs {}
#[argh(subcommand, name = "update")]
pub struct UpdateArgs {}
#[derive(FromArgs)]
/// import ssh hosts from the specified ssh config file, or from the default location
/// and save them as bookmarks.
#[argh(subcommand, name = "import-ssh-hosts")]
pub struct ImportSshHostsArgs {
#[argh(positional)]
/// optional ssh config file; if not specified, the default location will be used
pub ssh_config: Option<PathBuf>,
}
#[derive(FromArgs)]
/// import the specified theme
#[argh(subcommand, name = "theme")]
pub struct LoadThemeArgs {
pub struct ImportThemeArgs {
#[argh(positional)]
/// theme file
pub theme: PathBuf,
@@ -118,6 +132,14 @@ impl RunOpts {
}
}
pub fn import_ssh_hosts(ssh_config: Option<PathBuf>, keyring: bool) -> Self {
Self {
task: Task::ImportSshHosts(ssh_config),
keyring,
..Default::default()
}
}
pub fn import_theme(theme: PathBuf) -> Self {
Self {
task: Task::ImportTheme(theme),
+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
+5 -5
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
@@ -65,10 +65,10 @@ impl FileExplorerBuilder {
/// Set formatter for FileExplorer
pub fn with_formatter(&mut self, fmt_str: Option<&str>) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
if let Some(fmt_str) = fmt_str {
e.fmt = Formatter::new(fmt_str);
}
if let Some(e) = self.explorer.as_mut()
&& let Some(fmt_str) = fmt_str
{
e.fmt = Formatter::new(fmt_str);
}
self
}
+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()
}
+59 -42
View File
@@ -13,7 +13,10 @@ use remotefs_kube::KubeMultiPodFs as KubeFs;
use remotefs_smb::SmbOptions;
#[cfg(smb)]
use remotefs_smb::{SmbCredentials, SmbFs};
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))]
@@ -40,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)))
@@ -71,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);
@@ -102,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
@@ -121,34 +124,38 @@ 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)
}
}
/// Build scp client
fn scp_client(params: GenericProtocolParams, config_client: &ConfigClient) -> ScpFs {
Self::build_ssh_opts(params, config_client).into()
fn scp_client(
params: GenericProtocolParams,
config_client: &ConfigClient,
) -> 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 {
Self::build_ssh_opts(params, config_client).into()
fn sftp_client(
params: GenericProtocolParams,
config_client: &ConfigClient,
) -> 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);
@@ -163,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 {
@@ -188,7 +193,7 @@ impl RemoteFsBuilder {
credentials = credentials.password(password);
}
SmbFs::new(credentials)
Ok(SmbFs::new(credentials))
}
fn webdav_client(params: WebDAVProtocolParams) -> WebDAVFs {
@@ -226,19 +231,20 @@ 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.
if let Some(password) = params.password {
if !password.is_empty() {
opts = opts.password(password);
}
if let Some(password) = params.password
&& !password.is_empty()
{
opts = opts.password(password);
}
if let Some(config_path) = config_client.get_ssh_config() {
opts = opts.config_file(
@@ -253,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)
}
}
+26 -10
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,14 +20,10 @@ 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;
use std::path::{Path, PathBuf};
use std::time::Duration;
use self::activity_manager::{ActivityManager, NextActivity};
@@ -72,7 +73,10 @@ fn main() -> MainResult<()> {
fn parse_args(args: Args) -> Result<RunOpts, String> {
let run_opts = match args.nested {
Some(ArgsSubcommands::Update(_)) => RunOpts::update(),
Some(ArgsSubcommands::LoadTheme(args)) => RunOpts::import_theme(args.theme),
Some(ArgsSubcommands::ImportSshHosts(subargs)) => {
RunOpts::import_ssh_hosts(subargs.ssh_config, !args.wno_keyring)
}
Some(ArgsSubcommands::ImportTheme(args)) => RunOpts::import_theme(args.theme),
Some(ArgsSubcommands::Config(_)) => RunOpts::config(),
None => {
let mut run_opts: RunOpts = RunOpts::default();
@@ -111,10 +115,10 @@ fn parse_args(args: Args) -> Result<RunOpts, String> {
};
// Local directory
if let Some(localdir) = run_opts.remote.local_dir.as_deref() {
if let Err(err) = env::set_current_dir(localdir) {
return Err(format!("Bad working directory argument: {err}"));
}
if let Some(localdir) = run_opts.remote.local_dir.as_deref()
&& let Err(err) = env::set_current_dir(localdir)
{
return Err(format!("Bad working directory argument: {err}"));
}
run_opts
@@ -127,6 +131,7 @@ fn parse_args(args: Args) -> Result<RunOpts, String> {
/// Run task and return rc
fn run(run_opts: RunOpts) -> MainResult<()> {
match run_opts.task {
Task::ImportSshHosts(ssh_config) => run_import_ssh_hosts(ssh_config, run_opts.keyring),
Task::ImportTheme(theme) => run_import_theme(&theme),
Task::InstallUpdate => run_install_update(),
Task::Activity(activity) => {
@@ -145,6 +150,17 @@ fn print_version() -> MainResult<()> {
Ok(())
}
fn run_import_ssh_hosts(ssh_config_path: Option<PathBuf>, keyring: bool) -> MainResult<()> {
support::import_ssh_hosts(ssh_config_path, keyring)
.map(|_| {
println!("SSH hosts have been successfully imported!");
})
.map_err(|err| {
eprintln!("{err}");
err.into()
})
}
fn run_import_theme(theme: &Path) -> MainResult<()> {
match support::import_theme(theme) {
Ok(_) => {
+37 -1
View File
@@ -2,11 +2,14 @@
//!
//! this module exposes some extra run modes for termscp, meant to be used for "support", such as installing themes
// mod
mod import_ssh_hosts;
use std::fs;
use std::path::{Path, PathBuf};
pub use self::import_ssh_hosts::import_ssh_hosts;
use crate::system::auto_update::{Update, UpdateStatus};
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::config_client::ConfigClient;
use crate::system::environment;
use crate::system::notifications::Notification;
@@ -83,3 +86,36 @@ fn get_config_client() -> Option<ConfigClient> {
}
}
}
/// Init [`BookmarksClient`].
pub fn bookmarks_client(keyring: bool) -> Result<Option<BookmarksClient>, String> {
// Get config dir
match environment::init_config_dir() {
Ok(path) => {
// If some configure client, otherwise do nothing; don't bother users telling them that bookmarks are not supported on their system.
if let Some(config_dir_path) = path {
let bookmarks_file: PathBuf =
environment::get_bookmarks_paths(config_dir_path.as_path());
// Initialize client
BookmarksClient::new(
bookmarks_file.as_path(),
config_dir_path.as_path(),
16,
keyring,
)
.map(Option::Some)
.map_err(|e| {
format!(
"Could not initialize bookmarks (at \"{}\", \"{}\"): {}",
bookmarks_file.display(),
config_dir_path.display(),
e
)
})
} else {
Ok(None)
}
}
Err(err) => Err(err),
}
}
+332
View File
@@ -0,0 +1,332 @@
//! ## 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;
use ssh2_config::{Host, HostClause, ParseRule, SshConfig};
use crate::filetransfer::params::GenericProtocolParams;
use crate::filetransfer::{FileTransferParams, FileTransferProtocol, ProtocolParams};
/// Parameters required to add an ssh key for a host.
struct SshKeyParams {
host: String,
ssh_key: String,
username: String,
}
/// Import ssh hosts from the specified ssh config file, or from the default location
/// and save them as bookmarks.
pub fn import_ssh_hosts(ssh_config: Option<PathBuf>, keyring: bool) -> Result<(), String> {
// get config client
let mut cfg_client = super::get_config_client()
.ok_or_else(|| String::from("Could not import ssh hosts: could not load configuration"))?;
// resolve ssh_config
let ssh_config = ssh_config.or_else(|| cfg_client.get_ssh_config().map(PathBuf::from));
// load bookmarks client
let mut bookmarks_client = super::bookmarks_client(keyring)?
.ok_or_else(|| String::from("Could not import ssh hosts: could not load bookmarks"))?;
// load ssh config
let ssh_config = match ssh_config {
Some(p) => {
debug!("Importing ssh hosts from file: {}", p.display());
let mut reader = BufReader::new(
File::open(&p)
.map_err(|e| format!("Could not open ssh config file {}: {e}", p.display()))?,
);
SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
}
None => {
debug!("Importing ssh hosts from default location");
SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)
}
}
.map_err(|e| format!("Could not parse ssh config file: {e}"))?;
// iter hosts and add bookmarks
ssh_config
.get_hosts()
.iter()
.flat_map(host_to_params)
.for_each(|(name, params, identity_file_params)| {
debug!("Adding bookmark for host: {name} with params: {params:?}");
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 {
debug!(
"Host {host} has identity file, will add ssh key for it",
host = identity_file_params.host
);
if let Err(err) = cfg_client.add_ssh_key(
&identity_file_params.host,
&identity_file_params.username,
&identity_file_params.ssh_key,
) {
error!(
"Could not add ssh key for host {host}: {err}",
host = identity_file_params.host
);
}
}
});
// save bookmarks
if let Err(err) = bookmarks_client.write_bookmarks() {
return Err(format!(
"Could not save imported ssh hosts as bookmarks: {err}"
));
}
println!("Imported ssh hosts");
Ok(())
}
/// Tries to derive [`FileTransferParams`] from the specified ssh host.
fn host_to_params(
host: &Host,
) -> impl Iterator<Item = (String, FileTransferParams, Option<SshKeyParams>)> {
host.pattern
.iter()
.filter_map(|pattern| host_pattern_to_params(host, pattern))
}
/// Tries to derive [`FileTransferParams`] from the specified ssh host and pattern.
///
/// If `IdentityFile` is specified in the host parameters, it will be included in the returned tuple.
fn host_pattern_to_params(
host: &Host,
pattern: &HostClause,
) -> Option<(String, FileTransferParams, Option<SshKeyParams>)> {
debug!("Processing host with pattern: {pattern:?}",);
if pattern.negated || pattern.pattern.contains('*') || pattern.pattern.contains('?') {
debug!("Skipping host with pattern: {pattern}",);
return None;
}
let address = host
.params
.host_name
.as_deref()
.unwrap_or(pattern.pattern.as_str())
.to_string();
debug!("Resolved address for pattern {pattern}: {address}");
let port = host.params.port.unwrap_or(22);
debug!("Resolved port for pattern {pattern}: {port}");
let username = host.params.user.clone();
debug!("Resolved username for pattern {pattern}: {username:?}");
let identity_file_params = resolve_identity_file_path(host, pattern, &address);
Some((
pattern.to_string(),
FileTransferParams::new(
FileTransferProtocol::Sftp,
ProtocolParams::Generic(
GenericProtocolParams::default()
.address(address)
.port(port)
.username(username),
),
),
identity_file_params,
))
}
fn resolve_identity_file_path(
host: &Host,
pattern: &HostClause,
resolved_address: &str,
) -> Option<SshKeyParams> {
let (Some(username), Some(identity_file)) = (
host.params.user.as_ref(),
host.params.identity_file.as_ref().and_then(|v| v.first()),
) else {
debug!(
"No identity file specified for host {host}, skipping ssh key import",
host = pattern.pattern
);
return None;
};
// expand tilde
let identity_filepath = shellexpand::tilde(&identity_file.display().to_string()).to_string();
debug!("Resolved identity file for pattern {pattern}: {identity_filepath}",);
let Ok(mut ssh_file) = File::open(identity_file) else {
error!(
"Could not open identity file {identity_filepath} for host {host}",
host = pattern.pattern
);
return None;
};
let mut ssh_key = String::new();
use std::io::Read as _;
if let Err(err) = ssh_file.read_to_string(&mut ssh_key) {
error!(
"Could not read identity file {identity_filepath} for host {host}: {err}",
host = pattern.pattern
);
return None;
}
Some(SshKeyParams {
host: resolved_address.to_string(),
username: username.clone(),
ssh_key,
})
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use tempfile::NamedTempFile;
use super::*;
use crate::system::bookmarks_client::BookmarksClient;
#[test]
fn test_should_import_ssh_hosts() {
let ssh_test_config = ssh_test_config();
// import ssh hosts
let result = import_ssh_hosts(Some(ssh_test_config.config.path().to_path_buf()), false);
assert!(result.is_ok());
// verify imported hosts
let config_client = super::super::get_config_client()
.ok_or_else(|| String::from("Could not import ssh hosts: could not load configuration"))
.expect("failed to load config client");
// load bookmarks client
let bookmarks_client = super::super::bookmarks_client(false)
.expect("failed to load bookmarks client")
.expect("bookmarks client is none");
// verify bookmarks
check_bookmark(&bookmarks_client, "test1", "test1.example.com", 2200, None);
check_bookmark(
&bookmarks_client,
"test2",
"test2.example.com",
22,
Some("test2user"),
);
check_bookmark(
&bookmarks_client,
"test3",
"test3.example.com",
2222,
Some("test3user"),
);
// verify ssh keys
let (host, username, _key) = config_client
.get_ssh_key("test3user@test3.example.com")
.expect("ssh key is missing for test3user@test3.example.com");
assert_eq!(host, "test3.example.com");
assert_eq!(username, "test3user");
}
fn check_bookmark(
bookmarks_client: &BookmarksClient,
name: &str,
expected_address: &str,
expected_port: u16,
expected_username: Option<&str>,
) {
// verify bookmarks
let bookmark = bookmarks_client
.get_bookmark(name)
.expect("failed to get bookmark");
let params1 = bookmark
.params
.generic_params()
.expect("should have generic params");
assert_eq!(params1.address, expected_address);
assert_eq!(params1.port, expected_port);
assert_eq!(params1.username.as_deref(), expected_username);
assert!(params1.password.is_none());
}
struct SshTestConfig {
config: NamedTempFile,
_identity_file: NamedTempFile,
}
fn ssh_test_config() -> SshTestConfig {
use std::io::Write as _;
let mut identity_file = NamedTempFile::new().expect("failed to create tempfile");
writeln!(
identity_file,
r"-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAxKyYUMRCNPlb4ZV1VMofrzApu2l3wgP4Ot9wBvHsw/+RMpcHIbQK
9iQqAVp8Z+M1fJyPXTKjoJtIzuCLF6Sjo0KI7/tFTh+yPnA5QYNLZOIRZb8skumL4gwHww
5Z942FDPuUDQ30C2mZR9lr3Cd5pA8S1ZSPTAV9QQHkpgoS8cAL8QC6dp3CJjUC8wzvXh3I
oN3bTKxCpM10KMEVuWO3lM4Nvr71auB9gzo1sFJ3bwebCZIRH01FROyA/GXRiaOtJFG/9N
nWWI/iG5AJzArKpLZNHIP+FxV/NoRH0WBXm9Wq5MrBYrD1NQzm+kInpS/2sXk3m1aZWqLm
HF2NKRXSbQAAA8iI+KSniPikpwAAAAdzc2gtcnNhAAABAQDErJhQxEI0+VvhlXVUyh+vMC
m7aXfCA/g633AG8ezD/5EylwchtAr2JCoBWnxn4zV8nI9dMqOgm0jO4IsXpKOjQojv+0VO
H7I+cDlBg0tk4hFlvyyS6YviDAfDDln3jYUM+5QNDfQLaZlH2WvcJ3mkDxLVlI9MBX1BAe
SmChLxwAvxALp2ncImNQLzDO9eHcig3dtMrEKkzXQowRW5Y7eUzg2+vvVq4H2DOjWwUndv
B5sJkhEfTUVE7ID8ZdGJo60kUb/02dZYj+IbkAnMCsqktk0cg/4XFX82hEfRYFeb1arkys
FisPU1DOb6QielL/axeTebVplaouYcXY0pFdJtAAAAAwEAAQAAAP8u3PFuTVV5SfGazwIm
MgNaux82iOsAT/HWFWecQAkqqrruUw5f+YajH/riV61NE9aq2qNOkcJrgpTWtqpt980GGd
SHWlgpRWQzfIooEiDk6Pk8RVFZsEykkDlJQSIu2onZjhi5A5ojHgZoGGabDsztSqoyOjPq
6WPvGYRiDAR3leBMyp1WufBCJqAsC4L8CjPJSmnZhc5a0zXkC9Syz74Fa08tdM7bGhtvP1
GmzuYxkgxHH2IFeoumUSBHRiTZayGuRUDel6jgEiUMxenaDKXe7FpYzMm9tQZA10Mm4LhK
5rP9nd2/KRTFRnfZMnKvtIRC9vtlSLBe14qw+4ZCl60AAACAf1kghlO3+HIWplOmk/lCL0
w75Zz+RdvueL9UuoyNN1QrUEY420LsixgWSeRPby+Rb/hW+XSAZJQHowQ8acFJhU85So7f
4O4wcDuE4f6hpsW9tTfkCEUdLCQJ7EKLCrod6jIV7hvI6rvXiVucRpeAzdOaq4uzj2cwDd
tOdYVsnmQAAACBAOVxBsvO/Sr3rZUbNtA6KewZh/09HNGoKNaCeiD7vaSn2UJbbPRByF/o
Oo5zv8ee8r3882NnmG808XfSn7pPZAzbbTmOaJt0fmyZhivCghSNzV6njW3o0PdnC0fGZQ
ruVXgkd7RJFbsIiD4dDcF4VCjwWHfTK21EOgJUA5pN6TNvAAAAgQDbcJWRx8Uyhkj2+srb
3n2Rt6CR7kEl9cw17ItFjMn+pO81/5U2aGw0iLlX7E06TAMQC+dyW/WaxQRey8RRdtbJ1e
TNKCN34QCWkyuYRHGhcNc0quEDayPw5QWGXlP4BzjfRUcPxY9cCXLe5wDLYsX33HwOAc59
RorU9FCmS/654wAAABFyb290QDhjNTBmZDRjMzQ1YQECAw==
-----END OPENSSH PRIVATE KEY-----"
)
.expect("failed to write identity file");
let mut file = NamedTempFile::new().expect("failed to create tempfile");
// let's declare a couple of hosts
writeln!(
file,
r#"
Host test1
HostName test1.example.com
Port 2200
Host test2
HostName test2.example.com
User test2user
Host test3
HostName test3.example.com
User test3user
Port 2222
IdentityFile {identity_path}
"#,
identity_path = identity_file.path().display()
)
.expect("failed to write ssh config");
SshTestConfig {
config: file,
_identity_file: identity_file,
}
}
}
View File
+91 -3
View File
@@ -2,6 +2,8 @@
//!
//! Automatic update module. This module is used to upgrade the current version of termscp to the latest available on Github
use std::net::ToSocketAddrs as _;
use self_update::backends::github::Update as GithubUpdater;
pub use self_update::errors::Error as UpdateError;
use self_update::update::Release as UpdRelease;
@@ -21,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,
}
@@ -46,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()
@@ -67,6 +72,9 @@ impl Update {
/// otherwise if no version is available, return None
/// In case of error returns Error with the error description
pub fn is_new_version_available() -> Result<Option<Release>, UpdateError> {
// check if api.github.com is reachable before doing anything
Self::check_github_api_reachable()?;
info!("Checking whether a new version is available...");
GithubUpdater::configure()
// Set default options
@@ -83,6 +91,27 @@ impl Update {
.map(Self::check_version)
}
/// Check if api.github.com is reachable
/// This is useful to avoid long timeouts when the network is down
/// or the DNS is not working
fn check_github_api_reachable() -> Result<(), UpdateError> {
let Some(socket_addr) = ("api.github.com", 443)
.to_socket_addrs()
.ok()
.and_then(|mut i| i.next())
else {
error!("Could not resolve api.github.com");
return Err(UpdateError::Network(
"Could not resolve api.github.com".into(),
));
};
// just try to open a connection to api.github.com with a timeout of 5 seconds with tcp
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_secs(5))
.map(|_| ())
.map_err(|e| UpdateError::Network(format!("Could not reach api.github.com: {e}")))
}
/// In case received version is newer than current one, version as Some is returned; otherwise None
fn check_version(r: Release) -> Option<Release> {
debug!("got version from GitHub: {}", r.version);
@@ -106,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 {
@@ -204,12 +238,66 @@ 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);
+27 -16
View File
@@ -300,19 +300,18 @@ impl ConfigClient {
/// Get ssh key from host.
/// None is returned if key doesn't exist
/// `std::io::Error` is returned in case it was not possible to read the key file
pub fn get_ssh_key(&self, mkey: &str) -> std::io::Result<Option<SshHost>> {
pub fn get_ssh_key(&self, mkey: &str) -> Option<SshHost> {
if self.degraded {
return Ok(None);
return None;
}
// Check if Key exists
match self.config.remote.ssh_keys.get(mkey) {
None => Ok(None),
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
Ok(Some((host, username, PathBuf::from(key_path))))
Some((host, username, PathBuf::from(key_path)))
}
}
}
@@ -390,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`
@@ -451,7 +456,7 @@ mod tests {
// I/O
assert!(client.add_ssh_key("Omar", "omar", "omar").is_err());
assert!(client.del_ssh_key("omar", "omar").is_err());
assert!(client.get_ssh_key("omar").ok().unwrap().is_none());
assert!(client.get_ssh_key("omar").is_none());
assert!(client.write_config().is_err());
assert!(client.read_config().is_err());
}
@@ -493,7 +498,7 @@ mod tests {
let mut expected_key_path: PathBuf = key_path;
expected_key_path.push("pi@192.168.1.31.key");
assert_eq!(
client.get_ssh_key("pi@192.168.1.31").unwrap().unwrap(),
client.get_ssh_key("pi@192.168.1.31").unwrap(),
(
String::from("192.168.1.31"),
String::from("pi"),
@@ -684,7 +689,7 @@ mod tests {
);
// Iterate keys
for key in client.iter_ssh_keys() {
let host: SshHost = client.get_ssh_key(key).ok().unwrap().unwrap();
let host: SshHost = client.get_ssh_key(key).unwrap();
assert_eq!(host.0, String::from("192.168.1.31"));
assert_eq!(host.1, String::from("pi"));
let mut expected_key_path: PathBuf = key_path.clone();
@@ -699,7 +704,7 @@ mod tests {
assert_eq!(key, rsa_key);
}
// Unexisting key
assert!(client.get_ssh_key("test").ok().unwrap().is_none());
assert!(client.get_ssh_key("test").is_none());
// Delete key
assert!(client.del_ssh_key("192.168.1.31", "pi").is_ok());
}
@@ -712,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-Супер-Секрет";
+44 -11
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
@@ -103,17 +105,11 @@ impl From<&ConfigClient> for SshKeyStorage {
// Iterate over keys in storage
for key in cfg_client.iter_ssh_keys() {
match cfg_client.get_ssh_key(key) {
Ok(host) => match host {
Some((addr, username, rsa_key_path)) => {
let key_name: String = Self::make_mapkey(&addr, &username);
hosts.insert(key_name, rsa_key_path);
}
None => continue,
},
Err(err) => {
error!("Failed to get SSH key for {}: {}", key, err);
continue;
Some((addr, username, rsa_key_path)) => {
let key_name: String = Self::make_mapkey(&addr, &username);
hosts.insert(key_name, rsa_key_path);
}
None => continue,
}
info!("Got SSH key for {}", key);
}
@@ -212,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);
+26 -20
View File
@@ -30,13 +30,13 @@ impl AuthActivity {
pub(super) fn load_bookmark(&mut self, form_tab: FormTab, idx: usize) {
if let Some(bookmarks_cli) = self.bookmarks_client() {
// Iterate over bookmarks
if let Some(key) = self.bookmarks_list.get(idx) {
if let Some(bookmark) = bookmarks_cli.get_bookmark(key) {
// Load parameters into components
match form_tab {
FormTab::Remote => self.load_remote_bookmark_into_gui(bookmark),
FormTab::HostBridge => self.load_host_bridge_bookmark_into_gui(bookmark),
}
if let Some(key) = self.bookmarks_list.get(idx)
&& let Some(bookmark) = bookmarks_cli.get_bookmark(key)
{
// Load parameters into components
match form_tab {
FormTab::Remote => self.load_remote_bookmark_into_gui(bookmark),
FormTab::HostBridge => self.load_host_bridge_bookmark_into_gui(bookmark),
}
}
}
@@ -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
@@ -99,13 +102,13 @@ impl AuthActivity {
pub(super) fn load_recent(&mut self, form_tab: FormTab, idx: usize) {
if let Some(client) = self.bookmarks_client() {
// Iterate over bookmarks
if let Some(key) = self.recents_list.get(idx) {
if let Some(bookmark) = client.get_recent(key) {
// Load parameters
match form_tab {
FormTab::Remote => self.load_remote_bookmark_into_gui(bookmark),
FormTab::HostBridge => self.load_host_bridge_bookmark_into_gui(bookmark),
}
if let Some(key) = self.recents_list.get(idx)
&& let Some(bookmark) = client.get_recent(key)
{
// Load parameters
match form_tab {
FormTab::Remote => self.load_remote_bookmark_into_gui(bookmark),
FormTab::HostBridge => self.load_host_bridge_bookmark_into_gui(bookmark),
}
}
}
@@ -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();
}
@@ -129,10 +135,10 @@ impl AuthActivity {
/// Write bookmarks to file
fn write_bookmarks(&mut self) {
if let Some(bookmarks_cli) = self.bookmarks_client() {
if let Err(err) = bookmarks_cli.write_bookmarks() {
self.mount_error(format!("Could not write bookmarks: {err}").as_str());
}
if let Some(bookmarks_cli) = self.bookmarks_client()
&& let Err(err) = bookmarks_cli.write_bookmarks()
{
self.mount_error(format!("Could not write bookmarks: {err}").as_str());
}
}
@@ -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
}
}
+1 -4
View File
@@ -223,10 +223,7 @@ impl AuthActivity {
}
Err(err) => {
// Report error
error!("Failed to get latest version: {}", err);
self.mount_error(
format!("Could not check for new updates: {err}").as_str(),
);
error!("Failed to get latest version: {err}",);
}
}
} else {
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
)
}
}

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