Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions app/lib/services/ssh_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)));
}
};

Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions app/lib/widgets/local_file_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ class _LocalFilePanelState extends State<LocalFilePanel> {
child: PathBreadcrumb(
crumbs: crumbs,
onNavigate: prov.loadDirectory,
editablePath: prov.currentPath,
),
),
IconButton(
Expand Down
158 changes: 131 additions & 27 deletions app/lib/widgets/path_breadcrumb.dart
Original file line number Diff line number Diff line change
@@ -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});
Expand All @@ -17,40 +18,143 @@ List<PathCrumb> 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<PathCrumb> crumbs;
final ValueChanged<String> 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<PathBreadcrumb> createState() => _PathBreadcrumbState();
}

class _PathBreadcrumbState extends State<PathBreadcrumb> {
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(),
),
),
);
}
Expand Down
21 changes: 19 additions & 2 deletions app/lib/widgets/sftp_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,24 @@ class _SftpPanelState extends State<SftpPanel> {
/// Loads [path] and records it in the back/forward history (via setPath).
Future<void> _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
Expand Down Expand Up @@ -513,6 +529,7 @@ class _SftpPanelState extends State<SftpPanel> {
child: PathBreadcrumb(
crumbs: posixCrumbs(prov.currentPath),
onNavigate: _loadDirectory,
editablePath: prov.currentPath,
),
),
IconButton(
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 28 additions & 0 deletions app/test/services/ssh_service_open_shell_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Loading
Loading