diff --git a/CHANGELOG.md b/CHANGELOG.md index 84080c0..fa5b61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.35] — 2026-06-11 + +### Added +- **Breadcrumb path jump** — the shared `PathBreadcrumb` gains an inline path editor: an edit affordance opens a text field seeded with the current path; Enter navigates there, Escape cancels. Wired into both the remote SFTP panel and the local file panel; remote-typed paths are normalized to absolute POSIX (trailing slash dropped) so derived child paths don't double up + +### Fixed +- **Non-ASCII terminal input** — typed input was sent to the SSH shell via truncated UTF-16 code units, corrupting any character above U+00FF (e.g. Vietnamese: "ế" arrived as a single garbage byte). Input is now UTF-8 encoded at every user-text write site (keystroke/IME, `terminal.input` plugin hook, startup command, snippet insert), matching the local-shell path + +--- + ## [0.1.34] — 2026-06-08 ### Added diff --git a/app/lib/services/ssh_service.dart b/app/lib/services/ssh_service.dart index 9df83e6..e7cc1fd 100644 --- a/app/lib/services/ssh_service.dart +++ b/app/lib/services/ssh_service.dart @@ -648,7 +648,7 @@ class SshService { final initialCommand = session.initialCommand; if (initialCommand != null && initialCommand.isNotEmpty) { - shell.write(Uint8List.fromList('$initialCommand\n'.codeUnits)); + shell.write(Uint8List.fromList(const Utf8Encoder().convert('$initialCommand\n'))); } // Invisible shell-integration injection (two-phase handshake; see @@ -850,9 +850,13 @@ class SshService { final result = hookBus!.fireInterceptable( 'terminal.input', TransformEvent(sessionId: session.id, data: data)); if (result == null) return; // cancelled by plugin - shell.write(Uint8List.fromList(result.codeUnits)); + // Utf8Encoder (not codeUnits): non-ASCII input (e.g. Vietnamese) has + // code units > 0xFF that Uint8List.fromList truncates, corrupting the + // bytes the server receives. The local `utf8` decoder shadows + // dart:convert's, so use Utf8Encoder explicitly. + shell.write(Uint8List.fromList(const Utf8Encoder().convert(result))); } else { - shell.write(Uint8List.fromList(data.codeUnits)); + shell.write(Uint8List.fromList(const Utf8Encoder().convert(data))); } }; @@ -1234,7 +1238,8 @@ class SshService { bool sendInput(String sessionId, String text) { final shell = _shells[sessionId]; if (shell == null) return false; - shell.write(Uint8List.fromList(text.codeUnits)); + // Utf8Encoder (not codeUnits): snippet/insert text may be non-ASCII. + shell.write(Uint8List.fromList(const Utf8Encoder().convert(text))); return true; } diff --git a/app/lib/widgets/local_file_panel.dart b/app/lib/widgets/local_file_panel.dart index 36f5a07..1f42d32 100644 --- a/app/lib/widgets/local_file_panel.dart +++ b/app/lib/widgets/local_file_panel.dart @@ -432,6 +432,7 @@ class _LocalFilePanelState extends State { child: PathBreadcrumb( crumbs: crumbs, onNavigate: prov.loadDirectory, + editablePath: prov.currentPath, ), ), IconButton( diff --git a/app/lib/widgets/path_breadcrumb.dart b/app/lib/widgets/path_breadcrumb.dart index c81859b..e963328 100644 --- a/app/lib/widgets/path_breadcrumb.dart +++ b/app/lib/widgets/path_breadcrumb.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// One clickable segment of a [PathBreadcrumb]. typedef PathCrumb = ({String label, String path}); @@ -17,40 +18,143 @@ List posixCrumbs(String path) { /// Horizontal, scrollable row of clickable path segments. The last crumb is /// highlighted as the current directory. Owns no navigation logic — panels /// supply [crumbs] and handle [onNavigate]. -class PathBreadcrumb extends StatelessWidget { +/// +/// When [editablePath] is non-null, a trailing edit affordance lets the user +/// type an arbitrary path to jump to; the value seeds the inline editor and +/// submitting it routes through [onNavigate] just like a crumb tap. +class PathBreadcrumb extends StatefulWidget { final List crumbs; final ValueChanged onNavigate; + final String? editablePath; - const PathBreadcrumb({super.key, required this.crumbs, required this.onNavigate}); + const PathBreadcrumb({ + super.key, + required this.crumbs, + required this.onNavigate, + this.editablePath, + }); + + @override + State createState() => _PathBreadcrumbState(); +} + +class _PathBreadcrumbState extends State { + final TextEditingController _controller = TextEditingController(); + bool _editing = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _startEditing() { + final seed = widget.editablePath ?? ''; + _controller.text = seed; + _controller.selection = + TextSelection(baseOffset: 0, extentOffset: seed.length); + setState(() => _editing = true); + } + + void _cancel() { + if (!_editing) return; + setState(() => _editing = false); + } + + void _submit() { + final value = _controller.text.trim(); + setState(() => _editing = false); + if (value.isNotEmpty) widget.onNavigate(value); + } @override Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < crumbs.length; i++) ...[ - if (i > 0) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 2), - child: Icon(Icons.chevron_right, size: 13, color: Color(0xFF444444)), - ), - GestureDetector( - onTap: () => onNavigate(crumbs[i].path), - child: Text( - crumbs[i].label, - style: TextStyle( - color: i == crumbs.length - 1 - ? const Color(0xFFD4D4D4) - : const Color(0xFF666666), - fontSize: 12, - fontWeight: - i == crumbs.length - 1 ? FontWeight.w500 : FontWeight.normal, - ), - ), + if (_editing) return _buildEditor(); + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < widget.crumbs.length; i++) ...[ + if (i > 0) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.chevron_right, + size: 13, color: Color(0xFF444444)), + ), + GestureDetector( + onTap: () => widget.onNavigate(widget.crumbs[i].path), + child: Text( + widget.crumbs[i].label, + style: TextStyle( + color: i == widget.crumbs.length - 1 + ? const Color(0xFFD4D4D4) + : const Color(0xFF666666), + fontSize: 12, + fontWeight: i == widget.crumbs.length - 1 + ? FontWeight.w500 + : FontWeight.normal, + ), + ), + ), + ], + ], ), - ], - ], + ), + ), + if (widget.editablePath != null) + IconButton( + icon: const Icon(Icons.edit_outlined, + size: 13, color: Color(0xFF555555)), + onPressed: _startEditing, + tooltip: 'Go to path', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + ), + ], + ); + } + + Widget _buildEditor() { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): _cancel, + }, + child: SizedBox( + height: 28, + child: TextField( + controller: _controller, + autofocus: true, + cursorColor: const Color(0xFF22C55E), + style: const TextStyle( + color: Color(0xFFD4D4D4), fontSize: 12, fontFamily: 'monospace'), + decoration: InputDecoration( + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + hintText: 'Enter a path and press Enter…', + hintStyle: const TextStyle(color: Color(0xFF555555), fontSize: 12), + filled: true, + fillColor: const Color(0xFF1E1E1E), + prefixIcon: const Icon(Icons.subdirectory_arrow_right, + size: 14, color: Color(0xFF555555)), + prefixIconConstraints: + const BoxConstraints(minWidth: 26, minHeight: 24), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide(color: Color(0xFF2A2A2A))), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide(color: Color(0xFF2A2A2A))), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide(color: Color(0xFF22C55E))), + ), + onSubmitted: (_) => _submit(), + onTapOutside: (_) => _cancel(), + ), ), ); } diff --git a/app/lib/widgets/sftp_panel.dart b/app/lib/widgets/sftp_panel.dart index d498e4d..402ad76 100644 --- a/app/lib/widgets/sftp_panel.dart +++ b/app/lib/widgets/sftp_panel.dart @@ -88,8 +88,24 @@ class _SftpPanelState extends State { /// Loads [path] and records it in the back/forward history (via setPath). Future _loadDirectory(String path) async { if (widget.host == null) return; - widget.provider.setPath(path); - await _fetchEntries(path); + final normalized = _normalizeRemotePath(path); + widget.provider.setPath(normalized); + await _fetchEntries(normalized); + } + + /// Cleans up a path typed into the breadcrumb editor. Remote paths are + /// always absolute POSIX, so a relative entry resolves against root and a + /// trailing slash is dropped (except root) to keep derived child paths from + /// doubling up. Crumb/entry paths are already canonical, so this is a no-op + /// for them. + String _normalizeRemotePath(String input) { + var path = input.trim(); + if (path.isEmpty) return '/'; + if (!path.startsWith('/')) path = '/$path'; + if (path.length > 1 && path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + return path; } /// Lists [path] into the provider without touching the history — used by @@ -513,6 +529,7 @@ class _SftpPanelState extends State { child: PathBreadcrumb( crumbs: posixCrumbs(prov.currentPath), onNavigate: _loadDirectory, + editablePath: prov.currentPath, ), ), IconButton( diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a0decc2..d66bc61 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: yourssh description: YourSSH - A professional SSH client for macOS, Windows, and Linux with advanced features like SFTP, port forwarding, and a built-in code editor. publish_to: 'none' -version: 0.1.34+1 +version: 0.1.35+1 environment: sdk: ^3.12.0 diff --git a/app/test/services/ssh_service_open_shell_test.dart b/app/test/services/ssh_service_open_shell_test.dart index 360a29e..d754961 100644 --- a/app/test/services/ssh_service_open_shell_test.dart +++ b/app/test/services/ssh_service_open_shell_test.dart @@ -478,4 +478,32 @@ void main() { await shell.close(); await shellDone; }); + + test('typed non-ASCII input is sent UTF-8 encoded, not truncated codeUnits', + () async { + // Regression: input was sent via `Uint8List.fromList(data.codeUnits)`, + // which truncates each UTF-16 code unit to a byte — corrupting any + // character above U+00FF (e.g. "ế" U+1EBF → byte 0xBF) so the server + // received garbage. Vietnamese must round-trip as valid UTF-8. + final svc = SshService(StorageService()); + final host = + Host(label: 'f', host: 'e.com', username: 'u', shellIntegration: false); + final session = SshSession(host: host); + session.terminal.resize(80, 24); + final shell = _FakeShell(); + svc.debugSetClient(host.id, _FakeClient(shell)); + + final shellDone = svc.openShell(session); + await pumpEventQueue(); + + const vietnamese = 'Tiếng Việt ô ệ'; + session.terminal.onOutput?.call(vietnamese); + + // _FakeShell.write decodes bytes as UTF-8 — corrupt input would surface + // as replacement characters here. + expect(shell.writes, contains(vietnamese)); + + await shell.close(); + await shellDone; + }); } diff --git a/app/test/widgets/path_breadcrumb_test.dart b/app/test/widgets/path_breadcrumb_test.dart index c59852c..2821aea 100644 --- a/app/test/widgets/path_breadcrumb_test.dart +++ b/app/test/widgets/path_breadcrumb_test.dart @@ -1,11 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yourssh/widgets/path_breadcrumb.dart'; -Widget _harness(List crumbs, ValueChanged onNavigate) { +Widget _harness( + List crumbs, + ValueChanged onNavigate, { + String? editablePath, +}) { return MaterialApp( home: Scaffold( - body: PathBreadcrumb(crumbs: crumbs, onNavigate: onNavigate), + body: PathBreadcrumb( + crumbs: crumbs, + onNavigate: onNavigate, + editablePath: editablePath, + ), ), ); } @@ -67,4 +76,77 @@ void main() { expect(parent.style!.color, const Color(0xFF666666)); }); }); + + group('PathBreadcrumb path editing', () { + testWidgets('no edit affordance when editablePath is null', (tester) async { + await tester.pumpWidget(_harness(posixCrumbs('/home'), (_) {})); + expect(find.byTooltip('Go to path'), findsNothing); + }); + + testWidgets('tapping the edit affordance reveals a text field seeded ' + 'with the current path', (tester) async { + await tester.pumpWidget( + _harness(posixCrumbs('/home/user'), (_) {}, editablePath: '/home/user')); + expect(find.byType(TextField), findsNothing); + + await tester.tap(find.byTooltip('Go to path')); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + final field = tester.widget(find.byType(TextField)); + expect(field.controller!.text, '/home/user'); + }); + + testWidgets('submitting a typed path navigates to it', (tester) async { + String? navigated; + await tester.pumpWidget(_harness( + posixCrumbs('/home'), (p) => navigated = p, + editablePath: '/home')); + await tester.tap(find.byTooltip('Go to path')); + await tester.pump(); + + await tester.enterText(find.byType(TextField), '/var/log'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(navigated, '/var/log'); + // Editor collapses back to the breadcrumb after navigating. + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('blank submission does not navigate', (tester) async { + String? navigated; + await tester.pumpWidget(_harness( + posixCrumbs('/home'), (p) => navigated = p, + editablePath: '/home')); + await tester.tap(find.byTooltip('Go to path')); + await tester.pump(); + + await tester.enterText(find.byType(TextField), ' '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(navigated, isNull); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('Escape cancels editing without navigating', (tester) async { + String? navigated; + await tester.pumpWidget(_harness( + posixCrumbs('/home'), (p) => navigated = p, + editablePath: '/home')); + await tester.tap(find.byTooltip('Go to path')); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget); + + await tester.enterText(find.byType(TextField), '/etc'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + + expect(navigated, isNull); + expect(find.byType(TextField), findsNothing); + // Breadcrumb is back. + expect(find.text('home'), findsOneWidget); + }); + }); }