diff --git a/README.md b/README.md index aecc6a5..2e5f616 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Dart implementation of the protocol, and the terminal is password. - Customizable on-screen key row with modifiers, arrows, function keys, key repeat, latching modifiers, and your own text snippets and control-key combos. +- Upload key: pick phone files, photos, or videos, stream them to the machine + over SFTP into `~/.conduit/uploads//`, and insert the shell-quoted + remote path into the terminal, with per-machine directory and optional + cleanup of Conduit's own old uploads. - Saved global and per-machine snippets from the key row, with hidden snippets for passwords or secrets and optional per-machine run-on-connect snippets. - Optional device-auth app lock for protecting saved machines and credentials. diff --git a/lib/core/presentation/theme_sheet.dart b/lib/core/presentation/theme_sheet.dart index fa66ad0..7da1637 100644 --- a/lib/core/presentation/theme_sheet.dart +++ b/lib/core/presentation/theme_sheet.dart @@ -1078,6 +1078,7 @@ IconData _keyboardActionIcon(TerminalKeyboardAction action) { TerminalKeyboardAction.tmuxMenu => Icons.view_quilt_rounded, TerminalKeyboardAction.snippets => Icons.snippet_folder_rounded, TerminalKeyboardAction.compose => Icons.edit_note_rounded, + TerminalKeyboardAction.upload => Icons.upload_file_rounded, }; } diff --git a/lib/core/theme/app_palette.dart b/lib/core/theme/app_palette.dart index fc88815..c7a2762 100644 --- a/lib/core/theme/app_palette.dart +++ b/lib/core/theme/app_palette.dart @@ -18,6 +18,12 @@ enum AppPalette { palenight, synthwave, githubDark, + horizon, + iceberg, + sonokai, + zenburn, + carbonfox, + poimandres, } /// To add a palette: add a value to [AppPalette], then add its `PaletteSpec` @@ -1094,5 +1100,275 @@ extension AppPaletteDetails on AppPalette { ), terminalLight: _lightFallbackTerminal, ), + AppPalette.horizon => const PaletteSpec( + label: 'Horizon', + caption: 'Warm dusk pinks and teals', + accent: Color(0xFFE95678), + accentSecondary: Color(0xFF26BBD9), + canvas: Color(0xFF16161C), + panel: Color(0xFF1C1E26), + panelElevated: Color(0xFF232530), + hairline: Color(0xFF2E303E), + border: Color(0xFF3A3D4D), + foreground: Color(0xFFD5D8DA), + mutedForeground: Color(0xFF9DA0A2), + subtleForeground: Color(0xFF6C6F93), + success: Color(0xFF29D398), + warning: Color(0xFFFAB795), + danger: Color(0xFFE95678), + canvasLight: Color(0xFFFDF0ED), + foregroundLight: Color(0xFF1C1E26), + terminalDark: TerminalTheme( + cursor: Color(0xFFE95678), + selection: Color(0x662E303E), + foreground: Color(0xFFD5D8DA), + background: Color(0xFF16161C), + black: Color(0xFF16161C), + red: Color(0xFFE95678), + green: Color(0xFF29D398), + yellow: Color(0xFFFAB795), + blue: Color(0xFF26BBD9), + magenta: Color(0xFFEE64AC), + cyan: Color(0xFF59E1E3), + white: Color(0xFFD5D8DA), + brightBlack: Color(0xFF6C6F93), + brightRed: Color(0xFFEC6A88), + brightGreen: Color(0xFF3FDAA4), + brightYellow: Color(0xFFFBC3A7), + brightBlue: Color(0xFF3FC4DE), + brightMagenta: Color(0xFFF075B5), + brightCyan: Color(0xFF6BE4E6), + brightWhite: Color(0xFFE5E5E5), + searchHitBackground: Color(0xFFFAB795), + searchHitBackgroundCurrent: Color(0xFF29D398), + searchHitForeground: Color(0xFF16161C), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.iceberg => const PaletteSpec( + label: 'Iceberg', + caption: 'Cool bluish depths', + accent: Color(0xFF84A0C6), + accentSecondary: Color(0xFFA093C7), + canvas: Color(0xFF101219), + panel: Color(0xFF161821), + panelElevated: Color(0xFF1E2132), + hairline: Color(0xFF272C42), + border: Color(0xFF33374C), + foreground: Color(0xFFC6C8D1), + mutedForeground: Color(0xFF8389A3), + subtleForeground: Color(0xFF6B7089), + success: Color(0xFFB4BE82), + warning: Color(0xFFE2A478), + danger: Color(0xFFE27878), + canvasLight: Color(0xFFE8E9EC), + foregroundLight: Color(0xFF33374C), + terminalDark: TerminalTheme( + cursor: Color(0xFFC6C8D1), + selection: Color(0x661E2132), + foreground: Color(0xFFC6C8D1), + background: Color(0xFF101219), + black: Color(0xFF1E2132), + red: Color(0xFFE27878), + green: Color(0xFFB4BE82), + yellow: Color(0xFFE2A478), + blue: Color(0xFF84A0C6), + magenta: Color(0xFFA093C7), + cyan: Color(0xFF89B8C2), + white: Color(0xFFC6C8D1), + brightBlack: Color(0xFF6B7089), + brightRed: Color(0xFFE98989), + brightGreen: Color(0xFFC0CA8E), + brightYellow: Color(0xFFE9B189), + brightBlue: Color(0xFF91ACD1), + brightMagenta: Color(0xFFADA0D3), + brightCyan: Color(0xFF95C4CE), + brightWhite: Color(0xFFD2D4DE), + searchHitBackground: Color(0xFFE2A478), + searchHitBackgroundCurrent: Color(0xFF84A0C6), + searchHitForeground: Color(0xFF101219), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.sonokai => const PaletteSpec( + label: 'Sonokai', + caption: 'High-contrast vivid shades', + accent: Color(0xFF9ED072), + accentSecondary: Color(0xFFB39DF3), + canvas: Color(0xFF22242A), + panel: Color(0xFF2C2E34), + panelElevated: Color(0xFF33353F), + hairline: Color(0xFF3B3E48), + border: Color(0xFF45484F), + foreground: Color(0xFFE2E2E3), + mutedForeground: Color(0xFF9DA0A8), + subtleForeground: Color(0xFF7F8490), + success: Color(0xFF9ED072), + warning: Color(0xFFE7C664), + danger: Color(0xFFFC5D7C), + canvasLight: Color(0xFFF4F3F1), + foregroundLight: Color(0xFF33353F), + terminalDark: TerminalTheme( + cursor: Color(0xFFE2E2E3), + selection: Color(0x66414550), + foreground: Color(0xFFE2E2E3), + background: Color(0xFF22242A), + black: Color(0xFF181A1C), + red: Color(0xFFFC5D7C), + green: Color(0xFF9ED072), + yellow: Color(0xFFE7C664), + blue: Color(0xFF76CCE0), + magenta: Color(0xFFB39DF3), + cyan: Color(0xFF76CCE0), + white: Color(0xFFE2E2E3), + brightBlack: Color(0xFF7F8490), + brightRed: Color(0xFFFC5D7C), + brightGreen: Color(0xFF9ED072), + brightYellow: Color(0xFFE7C664), + brightBlue: Color(0xFF76CCE0), + brightMagenta: Color(0xFFB39DF3), + brightCyan: Color(0xFF76CCE0), + brightWhite: Color(0xFFF4F3F1), + searchHitBackground: Color(0xFFE7C664), + searchHitBackgroundCurrent: Color(0xFF9ED072), + searchHitForeground: Color(0xFF22242A), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.zenburn => const PaletteSpec( + label: 'Zenburn', + caption: 'Low-contrast classic calm', + accent: Color(0xFFF0DFAF), + accentSecondary: Color(0xFF8CD0D3), + canvas: Color(0xFF313131), + panel: Color(0xFF3F3F3F), + panelElevated: Color(0xFF494949), + hairline: Color(0xFF525252), + border: Color(0xFF5F5F5F), + foreground: Color(0xFFDCDCCC), + mutedForeground: Color(0xFFA8A897), + subtleForeground: Color(0xFF709080), + success: Color(0xFF60B48A), + warning: Color(0xFFF0DFAF), + danger: Color(0xFFDCA3A3), + canvasLight: Color(0xFFF2F1E9), + foregroundLight: Color(0xFF3F3F3F), + terminalDark: TerminalTheme( + cursor: Color(0xFFDCDCCC), + selection: Color(0x66709080), + foreground: Color(0xFFDCDCCC), + background: Color(0xFF313131), + black: Color(0xFF1E2320), + red: Color(0xFF705050), + green: Color(0xFF60B48A), + yellow: Color(0xFFDFAF8F), + blue: Color(0xFF506070), + magenta: Color(0xFFDC8CC3), + cyan: Color(0xFF8CD0D3), + white: Color(0xFFDCDCCC), + brightBlack: Color(0xFF709080), + brightRed: Color(0xFFDCA3A3), + brightGreen: Color(0xFFC3BF9F), + brightYellow: Color(0xFFF0DFAF), + brightBlue: Color(0xFF94BFF3), + brightMagenta: Color(0xFFEC93D3), + brightCyan: Color(0xFF93E0E3), + brightWhite: Color(0xFFFFFFFF), + searchHitBackground: Color(0xFFF0DFAF), + searchHitBackgroundCurrent: Color(0xFF93E0E3), + searchHitForeground: Color(0xFF313131), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.carbonfox => const PaletteSpec( + label: 'Carbonfox', + caption: 'Carbon black, neon glow', + accent: Color(0xFF78A9FF), + accentSecondary: Color(0xFFBE95FF), + canvas: Color(0xFF0C0C0C), + panel: Color(0xFF161616), + panelElevated: Color(0xFF252525), + hairline: Color(0xFF2A2A2A), + border: Color(0xFF3C3C3C), + foreground: Color(0xFFF2F4F8), + mutedForeground: Color(0xFFB6B8BB), + subtleForeground: Color(0xFF7B7C7E), + success: Color(0xFF25BE6A), + warning: Color(0xFF08BDBA), + danger: Color(0xFFEE5396), + canvasLight: Color(0xFFF4F4F5), + foregroundLight: Color(0xFF161616), + terminalDark: TerminalTheme( + cursor: Color(0xFFF2F4F8), + selection: Color(0x662A2A2A), + foreground: Color(0xFFF2F4F8), + background: Color(0xFF0C0C0C), + black: Color(0xFF282828), + red: Color(0xFFEE5396), + green: Color(0xFF25BE6A), + yellow: Color(0xFF08BDBA), + blue: Color(0xFF78A9FF), + magenta: Color(0xFFBE95FF), + cyan: Color(0xFF33B1FF), + white: Color(0xFFDFDFE0), + brightBlack: Color(0xFF484848), + brightRed: Color(0xFFF16DA6), + brightGreen: Color(0xFF46C880), + brightYellow: Color(0xFF2DC7C4), + brightBlue: Color(0xFF8CB6FF), + brightMagenta: Color(0xFFC8A5FF), + brightCyan: Color(0xFF52BDFF), + brightWhite: Color(0xFFE4E4E5), + searchHitBackground: Color(0xFF08BDBA), + searchHitBackgroundCurrent: Color(0xFF78A9FF), + searchHitForeground: Color(0xFF0C0C0C), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.poimandres => const PaletteSpec( + label: 'Poimandres', + caption: 'Minimal teal on deep slate', + accent: Color(0xFF5DE4C7), + accentSecondary: Color(0xFF89DDFF), + canvas: Color(0xFF171922), + panel: Color(0xFF1B1E28), + panelElevated: Color(0xFF252B37), + hairline: Color(0xFF2C3341), + border: Color(0xFF3A4150), + foreground: Color(0xFFE4F0FB), + mutedForeground: Color(0xFFA6ACCD), + subtleForeground: Color(0xFF767C9D), + success: Color(0xFF5DE4C7), + warning: Color(0xFFFFFAC2), + danger: Color(0xFFD0679D), + canvasLight: Color(0xFFEFF3F9), + foregroundLight: Color(0xFF1B1E28), + terminalDark: TerminalTheme( + cursor: Color(0xFF5DE4C7), + selection: Color(0x66303340), + foreground: Color(0xFFE4F0FB), + background: Color(0xFF171922), + black: Color(0xFF1B1E28), + red: Color(0xFFD0679D), + green: Color(0xFF5DE4C7), + yellow: Color(0xFFFFFAC2), + blue: Color(0xFF89DDFF), + magenta: Color(0xFFFCC5E9), + cyan: Color(0xFFADD7FF), + white: Color(0xFFFFFFFF), + brightBlack: Color(0xFF767C9D), + brightRed: Color(0xFFD0679D), + brightGreen: Color(0xFF5DE4C7), + brightYellow: Color(0xFFFFFAC2), + brightBlue: Color(0xFF89DDFF), + brightMagenta: Color(0xFFFCC5E9), + brightCyan: Color(0xFFADD7FF), + brightWhite: Color(0xFFFFFFFF), + searchHitBackground: Color(0xFFFFFAC2), + searchHitBackgroundCurrent: Color(0xFF5DE4C7), + searchHitForeground: Color(0xFF171922), + ), + terminalLight: _lightFallbackTerminal, + ), }; } diff --git a/lib/core/theme/terminal_appearance.dart b/lib/core/theme/terminal_appearance.dart index 3b1078e..0f7fff1 100644 --- a/lib/core/theme/terminal_appearance.dart +++ b/lib/core/theme/terminal_appearance.dart @@ -79,6 +79,7 @@ enum TerminalKeyboardAction { tmuxMenu, snippets, compose, + upload, } enum TerminalKeyboardItemKind { builtIn, customText, customControl } @@ -170,6 +171,7 @@ const defaultTerminalKeyboardActions = [ TerminalKeyboardAction.tmuxScrollback, TerminalKeyboardAction.tmuxMenu, TerminalKeyboardAction.snippets, + TerminalKeyboardAction.upload, TerminalKeyboardAction.fullscreen, ]; @@ -262,6 +264,7 @@ const defaultTerminalKeyboardItems = [ TerminalKeyboardItem.builtIn(TerminalKeyboardAction.tmuxScrollback), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.tmuxMenu), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.snippets), + TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.fullscreen), ]; @@ -383,5 +386,6 @@ extension TerminalKeyboardActionDetails on TerminalKeyboardAction { TerminalKeyboardAction.tmuxMenu => 'Tmux+', TerminalKeyboardAction.snippets => 'Snip', TerminalKeyboardAction.compose => 'Compose', + TerminalKeyboardAction.upload => 'Upload', }; } diff --git a/lib/features/hosts/domain/saved_host.dart b/lib/features/hosts/domain/saved_host.dart index 55af2cb..c5a623e 100644 --- a/lib/features/hosts/domain/saved_host.dart +++ b/lib/features/hosts/domain/saved_host.dart @@ -148,6 +148,8 @@ class SavedHost { this.tmuxStartDirectory = '', this.snippets = const [], this.connectSnippetId = '', + this.uploadDirectory = '', + this.uploadCleanupDays, this.lastConnectedAt, this.isLocal = false, }); @@ -188,6 +190,15 @@ class SavedHost { final String tmuxStartDirectory; final List snippets; final String connectSnippetId; + + /// Remote directory for phone-file uploads. Empty means the default + /// `~/.conduit/uploads/`; may be absolute or `~/`-relative. + final String uploadDirectory; + + /// Age in days after which Conduit-uploaded files are deleted on the next + /// upload. Null disables automatic cleanup (the default). + final int? uploadCleanupDays; + final DateTime? lastConnectedAt; final bool isLocal; @@ -261,6 +272,9 @@ class SavedHost { String? tmuxStartDirectory, List? snippets, String? connectSnippetId, + String? uploadDirectory, + int? uploadCleanupDays, + bool clearUploadCleanupDays = false, DateTime? lastConnectedAt, bool clearLastConnectedAt = false, bool? isLocal, @@ -292,6 +306,10 @@ class SavedHost { tmuxStartDirectory: tmuxStartDirectory ?? this.tmuxStartDirectory, snippets: snippets ?? this.snippets, connectSnippetId: connectSnippetId ?? this.connectSnippetId, + uploadDirectory: uploadDirectory ?? this.uploadDirectory, + uploadCleanupDays: clearUploadCleanupDays + ? null + : uploadCleanupDays ?? this.uploadCleanupDays, lastConnectedAt: clearLastConnectedAt ? null : lastConnectedAt ?? this.lastConnectedAt, @@ -332,6 +350,8 @@ class SavedHost { 'tmuxStartDirectory': tmuxStartDirectory, 'snippets': [for (final snippet in snippets) snippet.toJson()], 'connectSnippetId': connectSnippetId, + 'uploadDirectory': uploadDirectory, + 'uploadCleanupDays': uploadCleanupDays, 'lastConnectedAt': lastConnectedAt?.toIso8601String(), 'isLocal': isLocal, }; @@ -386,6 +406,11 @@ class SavedHost { .whereType() .toList(growable: false), connectSnippetId: json['connectSnippetId'] as String? ?? '', + uploadDirectory: (json['uploadDirectory'] as String?)?.trim() ?? '', + uploadCleanupDays: switch (json['uploadCleanupDays']) { + final int days when days > 0 => days, + _ => null, + }, lastConnectedAt: lastConnectedAtRaw == null ? null : DateTime.tryParse(lastConnectedAtRaw), diff --git a/lib/features/hosts/presentation/host_form_page.dart b/lib/features/hosts/presentation/host_form_page.dart index 05a4799..119d9ee 100644 --- a/lib/features/hosts/presentation/host_form_page.dart +++ b/lib/features/hosts/presentation/host_form_page.dart @@ -51,11 +51,13 @@ class _HostFormPageState extends State { text: defaultTmuxSessionName, ); final _tmuxStartDirectoryController = TextEditingController(); + final _uploadDirectoryController = TextEditingController(); final FocusNode _tagFocusNode = FocusNode(); SshAuthMethod _authMethod = SshAuthMethod.password; bool _showPassword = false; bool _showPassphrase = false; bool _useMosh = false; + int? _uploadCleanupDays; bool _predictiveEchoEnabled = false; bool _externalAuthOfferKey = true; bool _forwardAgent = false; @@ -116,6 +118,8 @@ class _HostFormPageState extends State { _tmuxStartDirectoryController.text = host.tmuxStartDirectory; _snippets = List.from(host.snippets); _connectSnippetId = host.connectSnippetId; + _uploadDirectoryController.text = host.uploadDirectory; + _uploadCleanupDays = host.uploadCleanupDays; } _keyInspection = _cheapPreview(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -196,6 +200,7 @@ class _HostFormPageState extends State { _moshPortsController.dispose(); _tmuxSessionNameController.dispose(); _tmuxStartDirectoryController.dispose(); + _uploadDirectoryController.dispose(); super.dispose(); } @@ -290,10 +295,12 @@ class _HostFormPageState extends State { moshPortsController: _moshPortsController, tmuxSessionNameController: _tmuxSessionNameController, tmuxStartDirectoryController: _tmuxStartDirectoryController, + uploadDirectoryController: _uploadDirectoryController, useMosh: _useMosh, predictiveEchoEnabled: _predictiveEchoEnabled, startTmuxOnConnect: _startTmuxOnConnect, tmuxPrefixKey: _tmuxPrefixKey, + uploadCleanupDays: _uploadCleanupDays, snippets: _snippets, connectSnippetId: _connectSnippetId, timeoutValidator: _validateTimeout, @@ -312,6 +319,8 @@ class _HostFormPageState extends State { setState(() => _startTmuxOnConnect = value), onTmuxPrefixKeyChanged: (value) => setState(() => _tmuxPrefixKey = value), + onUploadCleanupDaysChanged: (value) => + setState(() => _uploadCleanupDays = value), onSnippetsChanged: (snippets) => setState(() { _snippets = snippets; if (!_snippets.any( @@ -717,6 +726,8 @@ class _HostFormPageState extends State { ? defaultTmuxSessionName : _tmuxSessionNameController.text.trim(), tmuxStartDirectory: _tmuxStartDirectoryController.text.trim(), + uploadDirectory: _uploadDirectoryController.text.trim(), + uploadCleanupDays: _uploadCleanupDays, snippets: List.unmodifiable(_snippets), connectSnippetId: _snippets.any((snippet) => snippet.id == _connectSnippetId) diff --git a/lib/features/hosts/presentation/hosts_page.dart b/lib/features/hosts/presentation/hosts_page.dart index b33c1f5..79f3a22 100644 --- a/lib/features/hosts/presentation/hosts_page.dart +++ b/lib/features/hosts/presentation/hosts_page.dart @@ -23,6 +23,7 @@ import 'package:conduit/features/local_shell/presentation/local_shell_setup_page import 'package:conduit/features/local_shell/presentation/widgets/local_shell_section.dart'; import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; import 'package:conduit/features/sftp/presentation/sftp_browser_page.dart'; import 'package:conduit/features/terminal/domain/host_key_prompt.dart'; import 'package:conduit/features/terminal/domain/host_key_verifier.dart'; @@ -47,6 +48,7 @@ class HostsPage extends StatefulWidget { required this.hostKeyVerifier, required this.promptCoordinator, required this.sftpRepository, + required this.uploadManifestRepository, required this.backupService, required this.fileExport, super.key, @@ -61,6 +63,7 @@ class HostsPage extends StatefulWidget { final HostKeyVerifier hostKeyVerifier; final HostKeyPromptCoordinator promptCoordinator; final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; final AppBackupService backupService; final FileExport fileExport; @@ -440,6 +443,8 @@ class _HostsPageState extends State { builder: (_) => TerminalPage( workspace: widget.workspaceController, themeController: widget.themeController, + sftpRepository: widget.sftpRepository, + uploadManifestRepository: widget.uploadManifestRepository, ), ), ); diff --git a/lib/features/hosts/presentation/widgets/host_form_sections.dart b/lib/features/hosts/presentation/widgets/host_form_sections.dart index f9111cd..bace466 100644 --- a/lib/features/hosts/presentation/widgets/host_form_sections.dart +++ b/lib/features/hosts/presentation/widgets/host_form_sections.dart @@ -299,10 +299,12 @@ class HostAdvancedSection extends StatelessWidget { required this.moshPortsController, required this.tmuxSessionNameController, required this.tmuxStartDirectoryController, + required this.uploadDirectoryController, required this.useMosh, required this.predictiveEchoEnabled, required this.startTmuxOnConnect, required this.tmuxPrefixKey, + required this.uploadCleanupDays, required this.snippets, required this.connectSnippetId, required this.timeoutValidator, @@ -313,6 +315,7 @@ class HostAdvancedSection extends StatelessWidget { required this.onPredictiveEchoChanged, required this.onStartTmuxOnConnectChanged, required this.onTmuxPrefixKeyChanged, + required this.onUploadCleanupDaysChanged, required this.onSnippetsChanged, required this.onConnectSnippetChanged, super.key, @@ -326,10 +329,12 @@ class HostAdvancedSection extends StatelessWidget { final TextEditingController moshPortsController; final TextEditingController tmuxSessionNameController; final TextEditingController tmuxStartDirectoryController; + final TextEditingController uploadDirectoryController; final bool useMosh; final bool predictiveEchoEnabled; final bool startTmuxOnConnect; final TmuxPrefixKey tmuxPrefixKey; + final int? uploadCleanupDays; final List snippets; final String connectSnippetId; final FormFieldValidator timeoutValidator; @@ -340,6 +345,7 @@ class HostAdvancedSection extends StatelessWidget { final ValueChanged onPredictiveEchoChanged; final ValueChanged onStartTmuxOnConnectChanged; final ValueChanged onTmuxPrefixKeyChanged; + final ValueChanged onUploadCleanupDaysChanged; final ValueChanged> onSnippetsChanged; final ValueChanged onConnectSnippetChanged; @@ -494,6 +500,41 @@ class HostAdvancedSection extends StatelessWidget { } }, ), + const SizedBox(height: 16), + TextFormField( + controller: uploadDirectoryController, + decoration: const InputDecoration( + labelText: 'Upload directory', + hintText: '~/.conduit/uploads', + helperText: + 'Where the key-row Upload key stores phone files. Empty uses ' + '~/.conduit/uploads with one folder per day.', + helperMaxLines: 3, + prefixIcon: Icon(Icons.drive_folder_upload_outlined), + ), + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: uploadCleanupDays, + decoration: const InputDecoration( + labelText: 'Delete my uploads after', + helperText: + 'Only files Conduit itself uploaded are ever deleted, on the ' + 'next upload to this machine.', + helperMaxLines: 3, + prefixIcon: Icon(Icons.auto_delete_outlined), + ), + items: const [ + DropdownMenuItem(child: Text('Never')), + DropdownMenuItem(value: 7, child: Text('7 days')), + DropdownMenuItem(value: 30, child: Text('30 days')), + DropdownMenuItem(value: 90, child: Text('90 days')), + ], + onChanged: onUploadCleanupDaysChanged, + ), const SizedBox(height: 18), SnippetListEditor( title: 'Host snippets', diff --git a/lib/features/sftp/data/secure_upload_manifest_repository.dart b/lib/features/sftp/data/secure_upload_manifest_repository.dart new file mode 100644 index 0000000..86cd189 --- /dev/null +++ b/lib/features/sftp/data/secure_upload_manifest_repository.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Stores the per-host upload manifest as one JSON map +/// (`hostId -> [{path, at}]`) in secure storage. Corrupt payloads load as +/// an empty manifest; removing a host's last entry drops the host key. +class SecureUploadManifestRepository implements UploadManifestRepository { + const SecureUploadManifestRepository(this._storage); + + static const _key = 'conduit.upload_manifest.v1'; + + final FlutterSecureStorage _storage; + + @override + Future> entriesFor(String hostId) async { + final all = await _loadAll(); + return all[hostId] ?? const []; + } + + @override + Future setEntries( + String hostId, + List entries, + ) async { + final all = await _loadAll(); + if (entries.isEmpty) { + all.remove(hostId); + } else { + all[hostId] = List.of(entries); + } + await _storage.write( + key: _key, + value: jsonEncode({ + for (final MapEntry(:key, :value) in all.entries) + key: [for (final entry in value) entry.toJson()], + }), + ); + } + + Future>> _loadAll() async { + final raw = await _storage.read(key: _key); + if (raw == null || raw.trim().isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return {}; + } + final result = >{}; + for (final MapEntry(:key, :value) in decoded.entries) { + if (key is! String || value is! List) { + continue; + } + final entries = value + .map(UploadManifestEntry.fromJson) + .whereType() + .toList(); + if (entries.isNotEmpty) { + result[key] = entries; + } + } + return result; + } catch (_) { + return {}; + } + } +} diff --git a/lib/features/sftp/domain/upload_manifest.dart b/lib/features/sftp/domain/upload_manifest.dart new file mode 100644 index 0000000..16698a0 --- /dev/null +++ b/lib/features/sftp/domain/upload_manifest.dart @@ -0,0 +1,44 @@ +/// A record of one file Conduit uploaded to a host, used so automatic +/// cleanup can only ever touch files the app itself wrote. +class UploadManifestEntry { + const UploadManifestEntry({required this.path, required this.uploadedAt}); + + final String path; + final DateTime uploadedAt; + + Map toJson() { + return {'path': path, 'at': uploadedAt.toUtc().millisecondsSinceEpoch}; + } + + static UploadManifestEntry? fromJson(Object? json) { + if (json is! Map) { + return null; + } + final path = json['path']; + final at = json['at']; + if (path is! String || path.isEmpty || at is! int) { + return null; + } + return UploadManifestEntry( + path: path, + uploadedAt: DateTime.fromMillisecondsSinceEpoch(at, isUtc: true), + ); + } + + @override + bool operator ==(Object other) { + return other is UploadManifestEntry && + other.path == path && + other.uploadedAt == uploadedAt; + } + + @override + int get hashCode => Object.hash(path, uploadedAt); +} + +/// Per-host ledger of Conduit-owned remote uploads. +abstract class UploadManifestRepository { + Future> entriesFor(String hostId); + + Future setEntries(String hostId, List entries); +} diff --git a/lib/features/sftp/domain/upload_path.dart b/lib/features/sftp/domain/upload_path.dart new file mode 100644 index 0000000..4fc1dab --- /dev/null +++ b/lib/features/sftp/domain/upload_path.dart @@ -0,0 +1,99 @@ +/// Pure helpers for building safe remote upload paths. +library; + +/// Default remote directory (under the user's home) that uploads land in, +/// with one dated subdirectory per day. +const defaultUploadDirectoryName = '.conduit/uploads'; + +/// Formats the dated subdirectory for an upload batch: `yyyy-mm-dd`. +String uploadDateDirectory(DateTime now) { + String pad(int value) => value.toString().padLeft(2, '0'); + return '${now.year.toString().padLeft(4, '0')}-' + '${pad(now.month)}-${pad(now.day)}'; +} + +/// Resolves the absolute remote upload directory for a host. +/// +/// [home] is the server-resolved home directory. A [custom] directory from +/// host settings may be absolute, `~/`-relative, or home-relative; when +/// absent, uploads go to `$home/.conduit/uploads/`. +String resolveUploadDirectory({ + required String home, + required DateTime now, + String? custom, +}) { + final base = custom?.trim(); + final root = home.endsWith('/') ? home.substring(0, home.length - 1) : home; + if (base == null || base.isEmpty) { + return '$root/$defaultUploadDirectoryName/${uploadDateDirectory(now)}'; + } + if (base.startsWith('/')) { + return _stripTrailingSlash(base); + } + if (base == '~') { + return root; + } + if (base.startsWith('~/')) { + return _stripTrailingSlash('$root/${base.substring(2)}'); + } + return _stripTrailingSlash('$root/$base'); +} + +String _stripTrailingSlash(String path) { + return path.length > 1 && path.endsWith('/') + ? path.substring(0, path.length - 1) + : path; +} + +/// Makes a picked filename safe to use as a single remote path segment. +/// +/// Path separators and control characters are removed so a hostile or odd +/// filename cannot escape the upload directory; everything else — spaces, +/// Unicode, punctuation — is preserved for recognizability because inserted +/// paths are always shell-quoted separately. +String sanitizeUploadFileName(String name) { + var base = name.split('/').last.split(r'\').last; + base = base.replaceAll(RegExp(r'[\x00-\x1f\x7f]'), '').trim(); + if (base.isEmpty || base == '.' || base == '..') { + return 'file'; + } + return base; +} + +/// Picks a name that does not collide with [existing] entries, preserving +/// the extension: `photo.jpg` becomes `photo-143502.jpg` (upload time), and +/// `photo-143502-2.jpg` if even that is taken. +String resolveUploadCollision(String name, Set existing, DateTime now) { + if (!existing.contains(name)) { + return name; + } + final dot = name.lastIndexOf('.'); + final stem = dot > 0 ? name.substring(0, dot) : name; + final extension = dot > 0 ? name.substring(dot) : ''; + String pad(int value) => value.toString().padLeft(2, '0'); + final stamp = '${pad(now.hour)}${pad(now.minute)}${pad(now.second)}'; + var candidate = '$stem-$stamp$extension'; + var counter = 2; + while (existing.contains(candidate)) { + candidate = '$stem-$stamp-$counter$extension'; + counter += 1; + } + return candidate; +} + +const _posixSafePattern = r'^[A-Za-z0-9._/\-]+$'; + +/// Quotes [value] for safe literal use in a POSIX shell command line. +/// +/// Values made only of unambiguously safe characters pass through unquoted; +/// everything else is wrapped in single quotes with embedded single quotes +/// escaped as `'\''`, which keeps spaces, quotes, `$`, parentheses, newlines, +/// and globs literal in sh/bash/zsh/fish. +String posixShellQuote(String value) { + if (value.isNotEmpty && + !value.startsWith('-') && + RegExp(_posixSafePattern).hasMatch(value)) { + return value; + } + return "'${value.replaceAll("'", "'\\''")}'"; +} diff --git a/lib/features/terminal/presentation/terminal_keyboard_bar.dart b/lib/features/terminal/presentation/terminal_keyboard_bar.dart index a44f0ea..80393dd 100644 --- a/lib/features/terminal/presentation/terminal_keyboard_bar.dart +++ b/lib/features/terminal/presentation/terminal_keyboard_bar.dart @@ -23,6 +23,7 @@ class TerminalKeyboardBar extends StatelessWidget { required this.onToggleFullscreen, this.composeActive = false, this.onToggleCompose, + this.onUpload, required this.onEnterTmuxScrollMode, required this.onExitTmuxScrollMode, required this.tmuxPrefixKey, @@ -40,6 +41,9 @@ class TerminalKeyboardBar extends StatelessWidget { final VoidCallback onToggleFullscreen; final bool composeActive; final VoidCallback? onToggleCompose; + + /// Starts the phone-file upload flow for the active session. + final VoidCallback? onUpload; final VoidCallback onEnterTmuxScrollMode; final VoidCallback onExitTmuxScrollMode; final TmuxPrefixKey tmuxPrefixKey; @@ -232,6 +236,12 @@ class TerminalKeyboardBar extends StatelessWidget { onSelected: _triggerSnippetMenuItem, items: _snippetMenuItems(), ), + TerminalKeyboardAction.upload => _Key( + icon: Icons.upload_file_rounded, + palette: palette, + brightness: brightness, + onPressed: onUpload == null ? null : () => onUpload!(), + ), _ => _Key( label: action.label, palette: palette, @@ -286,6 +296,7 @@ class TerminalKeyboardBar extends StatelessWidget { case TerminalKeyboardAction.tmuxMenu: case TerminalKeyboardAction.snippets: case TerminalKeyboardAction.compose: + case TerminalKeyboardAction.upload: break; } } diff --git a/lib/features/terminal/presentation/terminal_page.dart b/lib/features/terminal/presentation/terminal_page.dart index 5a0912f..6021aa8 100644 --- a/lib/features/terminal/presentation/terminal_page.dart +++ b/lib/features/terminal/presentation/terminal_page.dart @@ -5,30 +5,47 @@ import 'package:conduit/core/presentation/system_navigation_insets.dart'; import 'package:conduit/core/theme/app_palette.dart'; import 'package:conduit/core/theme/terminal_appearance.dart'; import 'package:conduit/core/theme/theme_controller.dart'; +import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; import 'package:conduit/features/terminal/domain/security_key_interaction.dart'; import 'package:conduit/features/terminal/presentation/security_key_picker_dialog.dart'; import 'package:conduit/features/terminal/presentation/security_key_pin_dialog.dart'; import 'package:conduit/features/terminal/presentation/terminal_keyboard_bar.dart'; import 'package:conduit/features/terminal/presentation/terminal_session_controller.dart'; +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; import 'package:conduit/features/terminal/presentation/terminal_workspace_controller.dart'; import 'package:conduit/features/terminal/presentation/widgets/empty_terminal_state.dart'; import 'package:conduit/features/terminal/presentation/widgets/session_tabs.dart'; import 'package:conduit/features/terminal/presentation/widgets/terminal_header.dart'; import 'package:conduit/features/terminal/presentation/widgets/terminal_surface.dart'; +import 'package:conduit/features/terminal/presentation/widgets/terminal_upload_sheet.dart'; import 'package:conduit_vt/conduit_vt.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +/// Picks local files for upload; injectable so tests can avoid the native +/// file picker. Returns null when the user cancels. +typedef TerminalUploadFilePicker = Future?> Function(); + class TerminalPage extends StatefulWidget { const TerminalPage({ required this.workspace, required this.themeController, + required this.sftpRepository, + required this.uploadManifestRepository, + this.uploadFilePicker, super.key, }); final TerminalWorkspaceController workspace; final ThemeController themeController; + final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; + final TerminalUploadFilePicker? uploadFilePicker; @override State createState() => _TerminalPageState(); @@ -108,6 +125,84 @@ class _TerminalPageState extends State { _setSystemUiFullscreen(_fullscreen); } + Future?> _pickUploadFiles() async { + final custom = widget.uploadFilePicker; + if (custom != null) { + return custom(); + } + final FilePickerResult? result = await FilePicker.pickFiles( + allowMultiple: true, + withReadStream: true, + ); + if (result == null || result.files.isEmpty) { + return null; + } + final files = []; + for (final file in result.files) { + final readStream = file.readStream; + final path = file.path; + if (readStream == null && path == null) { + continue; + } + files.add( + readStream == null + ? SftpUploadFile.local( + localPath: path!, + name: file.name, + size: file.size, + ) + : SftpUploadFile( + source: () => readStream, + name: file.name, + size: file.size, + ), + ); + } + return files; + } + + Future _openUploadFlow(TerminalSessionController session) async { + if (session.host.isLocal) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Uploads need an SSH machine; the local shell has ' + 'no SFTP endpoint.', + ), + ), + ); + return; + } + final List? files; + try { + files = await _pickUploadFiles(); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not read the selected files.')), + ); + } + return; + } + if (files == null || files.isEmpty || !mounted) { + return; + } + final controller = TerminalUploadController( + host: session.host, + repository: widget.sftpRepository, + manifest: widget.uploadManifestRepository, + ); + controller.prepare(files); + await showTerminalUploadSheet( + context: context, + controller: controller, + // Insert without submitting so the user can add text around the paths. + onInsert: session.sendText, + ); + controller.dispose(); + _focusNode.requestFocus(); + } + void _setSystemUiFullscreen(bool fullscreen) { SystemChrome.setEnabledSystemUIMode( fullscreen ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, @@ -265,6 +360,8 @@ class _TerminalPageState extends State { composeActive: _composeMode, onToggleCompose: () => setState(() => _composeMode = !_composeMode), + onUpload: () => + unawaited(_openUploadFlow(activeSession)), tmuxPrefixKey: activeSession.host.tmuxPrefixKey, tmuxScrollMode: _tmuxScrollMode, onEnterTmuxScrollMode: () { diff --git a/lib/features/terminal/presentation/terminal_upload_controller.dart b/lib/features/terminal/presentation/terminal_upload_controller.dart new file mode 100644 index 0000000..ab605e3 --- /dev/null +++ b/lib/features/terminal/presentation/terminal_upload_controller.dart @@ -0,0 +1,288 @@ +// ignore_for_file: prefer_initializing_formals + +import 'dart:async'; + +import 'package:conduit/features/hosts/domain/saved_host.dart'; +import 'package:conduit/features/sftp/domain/sftp_entry.dart'; +import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/sftp_session.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/domain/upload_path.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; +import 'package:flutter/foundation.dart'; + +enum TerminalUploadPhase { + confirming, + connecting, + uploading, + success, + failed, + cancelled, +} + +/// Progress and result for one file within an upload batch. +class TerminalUploadItem { + TerminalUploadItem(this.file); + + final SftpUploadFile file; + String? remotePath; + int bytesSent = 0; + bool done = false; + + double? get fraction { + if (file.size <= 0) { + return done ? 1 : null; + } + return (bytesSent / file.size).clamp(0.0, 1.0); + } +} + +/// Uploads one batch of picked phone files to a host over SFTP. +/// +/// Owns a dedicated SFTP session for the batch (opened lazily, always closed +/// when the batch ends), builds the app-managed remote directory, avoids +/// name collisions, records every written file in the per-host upload +/// manifest, and — when the host opts into cleanup — deletes only +/// manifest-listed files older than the configured age. Nothing outside the +/// manifest is ever deleted, and directories are never removed. +class TerminalUploadController extends ChangeNotifier { + TerminalUploadController({ + required this.host, + required SftpRepository repository, + required UploadManifestRepository manifest, + DateTime Function()? now, + }) : _repository = repository, + _manifest = manifest, + _now = now ?? DateTime.now; + + final SavedHost host; + final SftpRepository _repository; + final UploadManifestRepository _manifest; + final DateTime Function() _now; + + TerminalUploadPhase _phase = TerminalUploadPhase.confirming; + List _items = const []; + String? _destination; + String? _error; + SftpSession? _session; + bool _cancelRequested = false; + bool _disposed = false; + + TerminalUploadPhase get phase => _phase; + List get items => List.unmodifiable(_items); + String? get destination => _destination; + String? get error => _error; + + /// Remote paths of the files that finished uploading. + List get uploadedPaths => [ + for (final item in _items) + if (item.done && item.remotePath != null) item.remotePath!, + ]; + + /// The uploaded paths, individually shell-quoted and space-separated, + /// ready to insert into a terminal command line. + String get quotedPathsForInsertion => + uploadedPaths.map(posixShellQuote).join(' '); + + void prepare(List files) { + _items = [for (final file in files) TerminalUploadItem(file)]; + _phase = TerminalUploadPhase.confirming; + _error = null; + _notify(); + } + + /// Requests cancellation of an in-flight batch. The session is closed so + /// the active transfer aborts promptly instead of draining. + Future cancel() async { + if (_phase != TerminalUploadPhase.uploading && + _phase != TerminalUploadPhase.connecting) { + return; + } + _cancelRequested = true; + final session = _session; + _session = null; + if (session != null) { + try { + await session.close(); + } catch (_) { + // The session may already be broken; cancellation still applies. + } + } + } + + Future uploadAll() async { + if (_phase == TerminalUploadPhase.uploading || + _phase == TerminalUploadPhase.connecting || + _items.isEmpty) { + return; + } + _cancelRequested = false; + _error = null; + _phase = TerminalUploadPhase.connecting; + _notify(); + SftpSession? session; + // Every file written to the server this batch, persisted to the + // manifest in the finally block so partial batches (failure or cancel + // mid-write) never leave unrecorded app-owned files behind. + final newEntries = []; + try { + session = await _repository.connect(host); + _session = session; + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + _notify(); + return; + } + final home = await session.resolve('.'); + final directory = resolveUploadDirectory( + home: home, + now: _now(), + custom: host.uploadDirectory, + ); + _destination = directory; + await _ensureDirectory(session, directory); + final existing = { + for (final entry in await session.list(directory)) entry.name, + }; + _phase = TerminalUploadPhase.uploading; + _notify(); + + for (final item in _items) { + if (_cancelRequested) { + break; + } + final name = resolveUploadCollision( + sanitizeUploadFileName(item.file.name), + existing, + _now(), + ); + existing.add(name); + final remotePath = '$directory/$name'; + await session.write( + remotePath, + item.file.openRead(), + item.file.size, + onProgress: (bytesSent) { + item.bytesSent = bytesSent; + _notify(); + }, + ); + item.remotePath = remotePath; + item.done = true; + newEntries.add( + UploadManifestEntry(path: remotePath, uploadedAt: _now().toUtc()), + ); + _notify(); + } + + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + } else { + // Cleanup of expired earlier uploads only runs after a fully + // successful batch; a cancelled or failed batch never deletes. + await _cleanUpExpired(session); + _phase = TerminalUploadPhase.success; + } + _notify(); + } catch (error) { + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + } else { + _phase = TerminalUploadPhase.failed; + _error = error.toString(); + } + _notify(); + } finally { + // Record everything that reached the server, even after a failure or + // cancellation, so cleanup stays able to manage these files later. + if (newEntries.isNotEmpty) { + try { + await _manifest.setEntries(host.id, [ + ...await _manifest.entriesFor(host.id), + ...newEntries, + ]); + } catch (_) { + // Manifest recording is best-effort; the upload result stands. + } + } + final open = _session; + _session = null; + if (open != null) { + try { + await open.close(); + } catch (_) { + // Closing a torn-down session is best-effort. + } + } + } + } + + /// Creates the target directory (and its parents) if missing. Individual + /// mkdir failures are tolerated — components usually already exist — and + /// the directory is verified afterwards by listing it. + Future _ensureDirectory(SftpSession session, String directory) async { + final segments = directory.split('/').where((s) => s.isNotEmpty).toList(); + var path = ''; + for (final segment in segments) { + path = '$path/$segment'; + try { + await session.makeDirectory(path); + } catch (_) { + // Most likely "already exists"; a real failure surfaces below. + } + } + } + + /// Deletes manifest-listed files older than the host's cleanup cutoff and + /// drops them from the manifest. Only ever touches exact paths this app + /// recorded; never directories. + Future _cleanUpExpired(SftpSession session) async { + final days = host.uploadCleanupDays; + if (days == null || days <= 0) { + return; + } + final entries = await _manifest.entriesFor(host.id); + if (entries.isEmpty) { + return; + } + final cutoff = _now().toUtc().subtract(Duration(days: days)); + final kept = []; + for (final entry in entries) { + if (entry.uploadedAt.isAfter(cutoff)) { + kept.add(entry); + continue; + } + try { + await session.delete( + SftpEntry( + name: entry.path.split('/').last, + path: entry.path, + kind: SftpEntryKind.file, + ), + ); + } catch (_) { + // Already gone or not deletable; either way the entry is dropped + // so a broken file cannot be retried forever. + } + } + await _manifest.setEntries(host.id, kept); + } + + void _notify() { + if (!_disposed) { + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + final open = _session; + _session = null; + if (open != null) { + unawaited(open.close().catchError((_) {})); + } + super.dispose(); + } +} diff --git a/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart b/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart new file mode 100644 index 0000000..59add99 --- /dev/null +++ b/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart @@ -0,0 +1,242 @@ +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Formats a byte count for display: `532 B`, `1.4 MB`, `2.1 GB`. +String formatUploadSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes.toDouble(); + var unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return '${value.toStringAsFixed(value >= 100 ? 0 : 1)} ${units[unit]}'; +} + +/// Shows the confirm → progress → result flow for one upload batch. +/// +/// The sheet drives [controller]; nothing uploads until the user confirms. +/// On success the user can insert the shell-quoted remote paths into the +/// terminal ([onInsert]) or copy them to the clipboard. +Future showTerminalUploadSheet({ + required BuildContext context, + required TerminalUploadController controller, + required ValueChanged onInsert, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + isDismissible: false, + enableDrag: false, + builder: (context) => + TerminalUploadSheet(controller: controller, onInsert: onInsert), + ); +} + +class TerminalUploadSheet extends StatelessWidget { + const TerminalUploadSheet({ + required this.controller, + required this.onInsert, + super.key, + }); + + final TerminalUploadController controller; + final ValueChanged onInsert; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (controller.destination != null) ...[ + Text( + controller.destination!, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 8), + ], + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final item in controller.items) + _UploadItemTile(item: item, phase: controller.phase), + ], + ), + ), + if (controller.phase == TerminalUploadPhase.failed) ...[ + const SizedBox(height: 8), + Text( + 'Upload failed. Nothing was inserted; already-uploaded ' + 'files stay on the server.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + _buildActions(context), + ], + ), + ); + }, + ); + } + + String get _title { + return switch (controller.phase) { + TerminalUploadPhase.confirming => 'Upload to server', + TerminalUploadPhase.connecting => 'Connecting…', + TerminalUploadPhase.uploading => 'Uploading…', + TerminalUploadPhase.success => 'Uploaded', + TerminalUploadPhase.failed => 'Upload failed', + TerminalUploadPhase.cancelled => 'Upload cancelled', + }; + } + + Widget _buildActions(BuildContext context) { + switch (controller.phase) { + case TerminalUploadPhase.confirming: + return Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Spacer(), + FilledButton.icon( + onPressed: controller.uploadAll, + icon: const Icon(Icons.upload_rounded), + label: const Text('Upload'), + ), + ], + ); + case TerminalUploadPhase.connecting: + case TerminalUploadPhase.uploading: + return Row( + children: [ + const Spacer(), + TextButton( + onPressed: controller.cancel, + child: const Text('Cancel'), + ), + ], + ); + case TerminalUploadPhase.success: + case TerminalUploadPhase.cancelled: + case TerminalUploadPhase.failed: + final paths = controller.quotedPathsForInsertion; + return Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + const Spacer(), + if (paths.isNotEmpty) ...[ + IconButton( + tooltip: 'Copy paths', + icon: const Icon(Icons.copy_rounded), + onPressed: () { + Clipboard.setData(ClipboardData(text: paths)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Remote paths copied.')), + ); + }, + ), + const SizedBox(width: 6), + FilledButton.icon( + onPressed: () { + onInsert(paths); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.keyboard_return_rounded), + label: const Text('Insert path'), + ), + ], + ], + ); + } + } +} + +class _UploadItemTile extends StatelessWidget { + const _UploadItemTile({required this.item, required this.phase}); + + final TerminalUploadItem item; + final TerminalUploadPhase phase; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = item.done + ? item.remotePath ?? formatUploadSize(item.file.size) + : '${formatUploadSize(item.bytesSent)} of ' + '${formatUploadSize(item.file.size)}'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + item.done + ? Icons.check_circle_rounded + : Icons.insert_drive_file_outlined, + size: 18, + color: item.done + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatUploadSize(item.file.size), + style: theme.textTheme.bodySmall, + ), + ], + ), + if (phase == TerminalUploadPhase.uploading && !item.done) ...[ + const SizedBox(height: 4), + LinearProgressIndicator(value: item.fraction), + ], + if (item.done || phase != TerminalUploadPhase.confirming) + Padding( + padding: const EdgeInsets.only(left: 26, top: 2), + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index edec549..983bb20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,8 +16,10 @@ import 'package:conduit/features/local_shell/local_shell_licenses.dart'; import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; import 'package:conduit/features/sftp/data/dart_ssh_sftp_repository.dart'; import 'package:conduit/features/sftp/data/file_picker_file_export.dart'; +import 'package:conduit/features/sftp/data/secure_upload_manifest_repository.dart'; import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; import 'package:conduit/features/terminal/data/connectivity_plus_network.dart'; import 'package:conduit/features/terminal/data/dart_ssh_terminal_repository.dart'; import 'package:conduit/features/terminal/data/mosh_terminal_repository.dart'; @@ -64,6 +66,9 @@ void main() { ConnectivityPlusNetwork(), ); final sftpRepository = DartSshSftpRepository(hostKeyVerifier); + const uploadManifestRepository = SecureUploadManifestRepository( + secureStorage, + ); final backupService = AppBackupService( hostsController: hostsController, themeController: themeController, @@ -84,6 +89,7 @@ void main() { hostKeyVerifier: hostKeyVerifier, promptCoordinator: promptCoordinator, sftpRepository: sftpRepository, + uploadManifestRepository: uploadManifestRepository, backupService: backupService, fileExport: fileExport, ), @@ -101,6 +107,7 @@ class ConduitApp extends StatefulWidget { required this.hostKeyVerifier, required this.promptCoordinator, required this.sftpRepository, + required this.uploadManifestRepository, required this.backupService, required this.fileExport, super.key, @@ -115,6 +122,7 @@ class ConduitApp extends StatefulWidget { final HostKeyVerifier hostKeyVerifier; final HostKeyPromptCoordinator promptCoordinator; final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; final AppBackupService backupService; final FileExport fileExport; @@ -258,6 +266,7 @@ class _ConduitAppState extends State with WidgetsBindingObserver { hostKeyVerifier: widget.hostKeyVerifier, promptCoordinator: widget.promptCoordinator, sftpRepository: widget.sftpRepository, + uploadManifestRepository: widget.uploadManifestRepository, backupService: widget.backupService, fileExport: widget.fileExport, ); diff --git a/pubspec.lock b/pubspec.lock index dd7c52a..13dcdef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -800,7 +800,7 @@ packages: source: hosted version: "1.5.2" wakelock_plus_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: wakelock_plus_platform_interface sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b diff --git a/pubspec.yaml b/pubspec.yaml index ea8c5c3..e458c4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + wakelock_plus_platform_interface: ^1.5.1 flutter_launcher_icons: android: "ic_launcher" diff --git a/test/core/system_navigation_insets_test.dart b/test/core/system_navigation_insets_test.dart index 1e25d2e..54d68ce 100644 --- a/test/core/system_navigation_insets_test.dart +++ b/test/core/system_navigation_insets_test.dart @@ -83,6 +83,7 @@ void main() { hostKeyVerifier: verifier, promptCoordinator: promptCoordinator, sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), backupService: AppBackupService( hostsController: hostsController, themeController: themeController, @@ -130,6 +131,7 @@ void main() { hostKeyVerifier: verifier, promptCoordinator: promptCoordinator, sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), backupService: AppBackupService( hostsController: hostsController, themeController: themeController, @@ -212,6 +214,8 @@ void main() { home: TerminalPage( workspace: workspace, themeController: themeController, + sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), ), ), ); diff --git a/test/core/theme_preferences_repository_test.dart b/test/core/theme_preferences_repository_test.dart index e43a1da..8f0e649 100644 --- a/test/core/theme_preferences_repository_test.dart +++ b/test/core/theme_preferences_repository_test.dart @@ -31,6 +31,7 @@ void main() { TerminalKeyboardAction.control, TerminalKeyboardAction.arrowDown, TerminalKeyboardAction.snippets, + TerminalKeyboardAction.upload, ], ); }, @@ -51,6 +52,7 @@ void main() { items: [ TerminalKeyboardItem.builtIn(TerminalKeyboardAction.escape), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.snippets), + TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload), ], ), ]); diff --git a/test/features/hosts/hosts_test.dart b/test/features/hosts/hosts_test.dart index ad2eb2c..856b2bb 100644 --- a/test/features/hosts/hosts_test.dart +++ b/test/features/hosts/hosts_test.dart @@ -169,6 +169,8 @@ void main() { ), ], connectSnippetId: 'snippet:deploy', + uploadDirectory: '~/drops', + uploadCleanupDays: 30, lastConnectedAt: DateTime.parse('2025-01-02T03:04:05Z'), ); @@ -198,9 +200,26 @@ void main() { expect(decoded.tmuxStartDirectory, original.tmuxStartDirectory); expect(decoded.snippets, original.snippets); expect(decoded.connectSnippetId, original.connectSnippetId); + expect(decoded.uploadDirectory, original.uploadDirectory); + expect(decoded.uploadCleanupDays, original.uploadCleanupDays); expect(decoded.lastConnectedAt, original.lastConnectedAt); }); + test('missing or corrupt upload settings load as defaults', () { + final decoded = SavedHost.fromJson(const { + 'id': 'id', + 'name': 'n', + 'host': 'h', + 'port': 22, + 'username': 'u', + 'authMethod': 'password', + 'uploadCleanupDays': 'soon', + }); + + expect(decoded.uploadDirectory, ''); + expect(decoded.uploadCleanupDays, isNull); + }); + test('invalid persisted mosh ports fall back to the default', () { final decoded = SavedHost.fromJson(const { 'id': 'id', diff --git a/test/features/sftp/upload_manifest_test.dart b/test/features/sftp/upload_manifest_test.dart new file mode 100644 index 0000000..5c9427d --- /dev/null +++ b/test/features/sftp/upload_manifest_test.dart @@ -0,0 +1,68 @@ +import 'package:conduit/features/sftp/data/secure_upload_manifest_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/test_doubles.dart'; + +void main() { + group('SecureUploadManifestRepository', () { + final entryA = UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-04/a.png', + uploadedAt: DateTime.utc(2026, 7, 4, 10), + ); + final entryB = UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-04/b.pdf', + uploadedAt: DateTime.utc(2026, 7, 4, 11), + ); + + test('round-trips entries per host', () async { + final storage = InMemorySecureStorage(); + final repository = SecureUploadManifestRepository(storage); + + await repository.setEntries('host-1', [entryA]); + await repository.setEntries('host-2', [entryB]); + + expect(await repository.entriesFor('host-1'), [entryA]); + expect(await repository.entriesFor('host-2'), [entryB]); + expect(await repository.entriesFor('host-3'), isEmpty); + }); + + test('drops a host key when its entries empty out', () async { + final storage = InMemorySecureStorage(); + final repository = SecureUploadManifestRepository(storage); + + await repository.setEntries('host-1', [entryA]); + await repository.setEntries('host-1', []); + + expect(await repository.entriesFor('host-1'), isEmpty); + final raw = await storage.read(key: 'conduit.upload_manifest.v1'); + expect(raw, isNot(contains('host-1'))); + }); + + test('loads corrupt payloads as an empty manifest', () async { + final storage = InMemorySecureStorage(); + await storage.write( + key: 'conduit.upload_manifest.v1', + value: 'not-json{{{', + ); + final repository = SecureUploadManifestRepository(storage); + + expect(await repository.entriesFor('host-1'), isEmpty); + }); + + test('skips malformed entries while keeping valid ones', () async { + final storage = InMemorySecureStorage(); + await storage.write( + key: 'conduit.upload_manifest.v1', + value: + '{"host-1":[{"path":"/ok","at":1000},{"path":"","at":2},' + '{"nope":true},42]}', + ); + final repository = SecureUploadManifestRepository(storage); + + final entries = await repository.entriesFor('host-1'); + expect(entries, hasLength(1)); + expect(entries.single.path, '/ok'); + }); + }); +} diff --git a/test/features/sftp/upload_path_test.dart b/test/features/sftp/upload_path_test.dart new file mode 100644 index 0000000..e3003f1 --- /dev/null +++ b/test/features/sftp/upload_path_test.dart @@ -0,0 +1,131 @@ +import 'package:conduit/features/sftp/domain/upload_path.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('resolveUploadDirectory', () { + final now = DateTime(2026, 7, 4); + + test('defaults to a dated folder under ~/.conduit/uploads', () { + expect( + resolveUploadDirectory(home: '/home/user', now: now), + '/home/user/.conduit/uploads/2026-07-04', + ); + }); + + test('trims a trailing slash from home', () { + expect( + resolveUploadDirectory(home: '/home/user/', now: now), + '/home/user/.conduit/uploads/2026-07-04', + ); + }); + + test('accepts absolute, tilde-relative, and home-relative overrides', () { + expect( + resolveUploadDirectory(home: '/h', now: now, custom: '/srv/drop/'), + '/srv/drop', + ); + expect( + resolveUploadDirectory(home: '/h', now: now, custom: '~/incoming'), + '/h/incoming', + ); + expect( + resolveUploadDirectory(home: '/h', now: now, custom: 'incoming'), + '/h/incoming', + ); + expect(resolveUploadDirectory(home: '/h', now: now, custom: '~'), '/h'); + }); + + test('treats blank overrides as the default', () { + expect( + resolveUploadDirectory(home: '/h', now: now, custom: ' '), + '/h/.conduit/uploads/2026-07-04', + ); + }); + }); + + group('sanitizeUploadFileName', () { + test('keeps ordinary names, spaces, and unicode intact', () { + expect( + sanitizeUploadFileName('Screenshot 2026.png'), + 'Screenshot 2026.png', + ); + expect(sanitizeUploadFileName('фото ☕.jpg'), 'фото ☕.jpg'); + }); + + test('strips directory components from path-like names', () { + expect(sanitizeUploadFileName('../../etc/passwd'), 'passwd'); + expect(sanitizeUploadFileName(r'C:\Users\me\doc.pdf'), 'doc.pdf'); + }); + + test('removes control characters', () { + expect(sanitizeUploadFileName('bad\x00name\x1f.txt'), 'badname.txt'); + }); + + test('falls back for empty or dot names', () { + expect(sanitizeUploadFileName(''), 'file'); + expect(sanitizeUploadFileName('..'), 'file'); + expect(sanitizeUploadFileName(' '), 'file'); + }); + }); + + group('resolveUploadCollision', () { + final now = DateTime(2026, 7, 4, 14, 35, 2); + + test('keeps a non-colliding name', () { + expect(resolveUploadCollision('a.jpg', {'b.jpg'}, now), 'a.jpg'); + }); + + test('adds a timestamp before the extension on collision', () { + expect( + resolveUploadCollision('photo.jpg', {'photo.jpg'}, now), + 'photo-143502.jpg', + ); + }); + + test('adds a counter when the timestamped name also collides', () { + expect( + resolveUploadCollision('photo.jpg', { + 'photo.jpg', + 'photo-143502.jpg', + }, now), + 'photo-143502-2.jpg', + ); + }); + + test('handles extensionless and dotfile names', () { + expect(resolveUploadCollision('notes', {'notes'}, now), 'notes-143502'); + expect( + resolveUploadCollision('.envrc', {'.envrc'}, now), + '.envrc-143502', + ); + }); + }); + + group('posixShellQuote', () { + test('passes through unambiguously safe values', () { + expect(posixShellQuote('/home/user/file.txt'), '/home/user/file.txt'); + expect(posixShellQuote('a-b_c.1/d'), 'a-b_c.1/d'); + }); + + test('quotes spaces, globs, and metacharacters', () { + expect(posixShellQuote('my file.txt'), "'my file.txt'"); + expect(posixShellQuote(r'a$(rm -rf).txt'), r"'a$(rm -rf).txt'"); + expect(posixShellQuote('a*b?.txt'), "'a*b?.txt'"); + expect(posixShellQuote('semi;colon'), "'semi;colon'"); + expect(posixShellQuote('(parens).pdf'), "'(parens).pdf'"); + }); + + test('escapes embedded single quotes', () { + expect(posixShellQuote("it's here.txt"), r"'it'\''s here.txt'"); + }); + + test('quotes newlines and leading dashes', () { + expect(posixShellQuote('line\nbreak'), "'line\nbreak'"); + expect(posixShellQuote('-rf'), "'-rf'"); + }); + + test('quotes the empty string', () { + expect(posixShellQuote(''), "''"); + }); + }); +} diff --git a/test/features/terminal/terminal_upload_test.dart b/test/features/terminal/terminal_upload_test.dart new file mode 100644 index 0000000..916b898 --- /dev/null +++ b/test/features/terminal/terminal_upload_test.dart @@ -0,0 +1,449 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:conduit/core/theme/terminal_appearance.dart'; +import 'package:conduit/core/theme/theme_controller.dart'; +import 'package:conduit/features/hosts/domain/saved_host.dart'; +import 'package:conduit/features/sftp/domain/sftp_entry.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; +import 'package:conduit/features/terminal/presentation/terminal_page.dart'; +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; +import 'package:conduit/features/terminal/presentation/terminal_workspace_controller.dart'; +import 'package:conduit/features/terminal/presentation/widgets/terminal_upload_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +import '../../support/test_doubles.dart'; + +class _NoopWakelock extends WakelockPlusPlatformInterface { + @override + Future toggle({required bool enable}) async {} + + @override + Future get enabled async => false; +} + +SftpEntry _entryNamed(String name) { + return SftpEntry(name: name, path: '/x/$name', kind: SftpEntryKind.file); +} + +SftpUploadFile _file(String name, List bytes) { + return SftpUploadFile( + source: () => Stream.value(Uint8List.fromList(bytes)), + name: name, + size: bytes.length, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WakelockPlusPlatformInterface.instance = _NoopWakelock(); + + final fixedNow = DateTime(2026, 7, 4, 14, 35, 2); + + group('TerminalUploadController', () { + late FakeSftpSession session; + late InMemoryUploadManifest manifest; + + TerminalUploadController buildController({ + SavedHost? host, + Object? writeError, + }) { + session = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + writeError: writeError, + ); + manifest = InMemoryUploadManifest(); + return TerminalUploadController( + host: host ?? buildHost('h1'), + repository: FakeSftpRepository(session), + manifest: manifest, + now: () => fixedNow, + ); + } + + test( + 'creates the dated directory chain and uploads with progress', + () async { + final controller = buildController(); + final progress = []; + controller.addListener(() => progress.add(controller.phase)); + controller.prepare([ + _file('photo one.jpg', [1, 2, 3, 4]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect(session.madeDirectories, [ + '/home', + '/home/u', + '/home/u/.conduit', + '/home/u/.conduit/uploads', + '/home/u/.conduit/uploads/2026-07-04', + ]); + const path = '/home/u/.conduit/uploads/2026-07-04/photo one.jpg'; + expect(session.writtenFiles[path], [1, 2, 3, 4]); + expect(controller.uploadedPaths, [path]); + expect( + controller.quotedPathsForInsertion, + "'/home/u/.conduit/uploads/2026-07-04/photo one.jpg'", + ); + expect(manifest.entries['h1'], hasLength(1)); + expect(session.closeCount, 1); + }, + ); + + test('sanitizes hostile names and resolves collisions', () async { + final controller = buildController(); + session.tree['/home/u/.conduit/uploads/2026-07-04'] = [ + _entryNamed('photo.jpg'), + ]; + // Pre-seeding the tree means makeDirectory must not clobber it. + controller.prepare([ + _file('../../photo.jpg', [1]), + _file('photo.jpg', [2]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect(controller.uploadedPaths, [ + '/home/u/.conduit/uploads/2026-07-04/photo-143502.jpg', + '/home/u/.conduit/uploads/2026-07-04/photo-143502-2.jpg', + ]); + }); + + test('uploads to a custom per-host directory', () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadDirectory: '~/drop zone'), + ); + controller.prepare([ + _file('a.txt', [7]), + ]); + + await controller.uploadAll(); + + expect(controller.uploadedPaths, ['/home/u/drop zone/a.txt']); + expect(controller.quotedPathsForInsertion, "'/home/u/drop zone/a.txt'"); + }); + + test( + 'reports failure and keeps state recoverable on write errors', + () async { + final controller = buildController(writeError: StateError('disk full')); + controller.prepare([ + _file('a.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + expect(controller.error, contains('disk full')); + expect(controller.uploadedPaths, isEmpty); + expect(manifest.entries, isEmpty); + expect(session.closeCount, 1); + }, + ); + + test('records already-uploaded files in the manifest when a later file ' + 'fails', () async { + session = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + failWriteAtIndex: 1, + ); + manifest = InMemoryUploadManifest(); + final controller = TerminalUploadController( + host: buildHost('h1'), + repository: FakeSftpRepository(session), + manifest: manifest, + now: () => fixedNow, + ); + controller.prepare([ + _file('first.txt', [1]), + _file('second.txt', [2]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + // The first file reached the server, so cleanup must know about it. + expect(manifest.entries['h1']!.map((e) => e.path), [ + '/home/u/.conduit/uploads/2026-07-04/first.txt', + ]); + // A failed batch never deletes anything. + expect(session.deletedPaths, isEmpty); + }); + + test('a cancelled batch records uploads but never deletes', () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadCleanupDays: 7), + ); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/old/expired.png', + uploadedAt: DateTime.utc(2020), + ), + ]; + controller.prepare([ + _file('a.txt', [1]), + _file('b.txt', [2]), + ]); + controller.addListener(() { + if (controller.items.first.done && + controller.phase == TerminalUploadPhase.uploading) { + controller.cancel(); + } + }); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.cancelled); + expect(session.deletedPaths, isEmpty); + expect( + manifest.entries['h1']!.map((e) => e.path), + containsAll([ + '/old/expired.png', + '/home/u/.conduit/uploads/2026-07-04/a.txt', + ]), + ); + }); + + test('fails cleanly when the connection cannot be opened', () async { + final controller = TerminalUploadController( + host: buildHost('h1'), + repository: ThrowingSftpRepository(), + manifest: InMemoryUploadManifest(), + now: () => fixedNow, + ); + controller.prepare([ + _file('a.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + expect(controller.error, isNotNull); + }); + + test( + 'cancelling between files stops the batch and closes the session', + () async { + final controller = buildController(); + controller.prepare([ + _file('a.txt', [1]), + _file('b.txt', [2]), + ]); + controller.addListener(() { + if (controller.items.first.done && + controller.phase == TerminalUploadPhase.uploading) { + controller.cancel(); + } + }); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.cancelled); + expect(controller.uploadedPaths, hasLength(1)); + expect(session.writtenFiles.keys, hasLength(1)); + expect(session.closeCount, greaterThanOrEqualTo(1)); + }, + ); + + test('zero-byte files upload successfully', () async { + final controller = buildController(); + controller.prepare([_file('empty.txt', [])]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect( + session.writtenFiles['/home/u/.conduit/uploads/2026-07-04/empty.txt'], + isEmpty, + ); + }); + + test( + 'cleanup deletes only manifest-listed files past the cutoff', + () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadCleanupDays: 7), + ); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-06-01/old.png', + uploadedAt: DateTime.utc(2026, 6), + ), + UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-03/fresh.png', + uploadedAt: DateTime.utc(2026, 7, 3), + ), + ]; + controller.prepare([ + _file('new.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(session.deletedPaths, [ + '/home/u/.conduit/uploads/2026-06-01/old.png', + ]); + final kept = manifest.entries['h1']!.map((e) => e.path).toList(); + expect( + kept, + isNot(contains('/home/u/.conduit/uploads/2026-06-01/old.png')), + ); + expect(kept, contains('/home/u/.conduit/uploads/2026-07-03/fresh.png')); + expect(kept, contains('/home/u/.conduit/uploads/2026-07-04/new.txt')); + }, + ); + + test('never deletes when cleanup is disabled', () async { + final controller = buildController(); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/somewhere/ancient.png', + uploadedAt: DateTime.utc(2020), + ), + ]; + controller.prepare([ + _file('new.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(session.deletedPaths, isEmpty); + expect( + manifest.entries['h1']!.map((e) => e.path), + contains('/somewhere/ancient.png'), + ); + }); + }); + + group('Terminal upload flow', () { + testWidgets('picker → confirm → progress → insert quoted path', ( + tester, + ) async { + final terminalSession = TrackableTerminalSession(); + final workspace = TerminalWorkspaceController( + ImmediateTerminalRepository(terminalSession), + ); + final themeController = ThemeController(InMemoryThemePreferences()); + final sftpSession = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + ); + final controller = workspace.open(buildHost('h1')); + await tester.runAsync(controller.connect); + addTearDown(workspace.dispose); + + await tester.pumpWidget( + MaterialApp( + home: TerminalPage( + workspace: workspace, + themeController: themeController, + sftpRepository: FakeSftpRepository(sftpSession), + uploadManifestRepository: InMemoryUploadManifest(), + uploadFilePicker: () async => [ + _file('my shot.png', [9, 9]), + ], + ), + ), + ); + await tester.pump(); + await themeController.setTerminalKeyboardRows(const [ + TerminalKeyboardRow( + items: [TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload)], + ), + ]); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.upload_file_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + // Confirmation state: filename, size, and explicit Upload action. + expect(find.byType(TerminalUploadSheet), findsOneWidget); + expect(find.text('my shot.png'), findsOneWidget); + expect(find.text('2 B'), findsOneWidget); + expect(sftpSession.writtenFiles, isEmpty); + + await tester.tap(find.text('Upload')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Uploaded'), findsOneWidget); + expect(sftpSession.writtenFiles.keys.single, endsWith('/my shot.png')); + + await tester.tap(find.text('Insert path')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byType(TerminalUploadSheet), findsNothing); + final sent = terminalSession.sent + .map((bytes) => utf8.decode(bytes)) + .join(); + expect(sent, contains("'")); + expect(sent, contains('/my shot.png')); + // Inserted, not executed: no Enter was sent. + expect(sent, isNot(contains('\r'))); + + // Flush the terminal resize debounce timer. + await tester.pump(const Duration(milliseconds: 300)); + }); + + testWidgets('failed uploads surface an error state', (tester) async { + final workspace = TerminalWorkspaceController( + ImmediateTerminalRepository(TrackableTerminalSession()), + ); + final themeController = ThemeController(InMemoryThemePreferences()); + final controller = workspace.open(buildHost('h1')); + await tester.runAsync(controller.connect); + addTearDown(workspace.dispose); + + await tester.pumpWidget( + MaterialApp( + home: TerminalPage( + workspace: workspace, + themeController: themeController, + sftpRepository: ThrowingSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), + uploadFilePicker: () async => [ + _file('a.txt', [1]), + ], + ), + ), + ); + await tester.pump(); + await themeController.setTerminalKeyboardRows(const [ + TerminalKeyboardRow( + items: [TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload)], + ), + ]); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.upload_file_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.text('Upload')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Upload failed'), findsOneWidget); + expect(find.textContaining('Nothing was inserted'), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(TerminalUploadSheet), findsNothing); + + await tester.pump(const Duration(milliseconds: 300)); + }); + }); +} diff --git a/test/support/test_doubles.dart b/test/support/test_doubles.dart index 1315a25..67b4259 100644 --- a/test/support/test_doubles.dart +++ b/test/support/test_doubles.dart @@ -11,6 +11,7 @@ import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_entry.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; import 'package:conduit/features/sftp/domain/sftp_session.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; import 'package:conduit/features/terminal/domain/host_key_prompt.dart'; import 'package:conduit/features/terminal/domain/host_key_verifier.dart'; import 'package:conduit/features/terminal/domain/network_connectivity.dart'; @@ -426,6 +427,26 @@ class NoopVerifier implements HostKeyVerifier { }) async => false; } +class InMemoryUploadManifest implements UploadManifestRepository { + final Map> entries = {}; + + @override + Future> entriesFor(String hostId) async => + List.of(entries[hostId] ?? const []); + + @override + Future setEntries( + String hostId, + List newEntries, + ) async { + if (newEntries.isEmpty) { + entries.remove(hostId); + } else { + entries[hostId] = List.of(newEntries); + } + } +} + class NoNetworkSftpRepository implements SftpRepository { @override Future connect(SavedHost host) { @@ -448,13 +469,27 @@ class ThrowingSftpRepository implements SftpRepository { } class FakeSftpSession implements SftpSession { - FakeSftpSession({required this.home, required this.tree}); + FakeSftpSession({ + required this.home, + required this.tree, + this.writeError, + this.failWriteAtIndex, + }); final String home; final Map> tree; final List madeDirectories = []; final Map> writtenFiles = {}; final Map listCalls = {}; + final List deletedPaths = []; + int closeCount = 0; + + /// When set, every write throws this error after draining nothing. + final Object? writeError; + + /// When set, only the write with this zero-based index throws. + final int? failWriteAtIndex; + int _writeCount = 0; @override Future> list(String path) async { @@ -486,6 +521,12 @@ class FakeSftpSession implements SftpSession { int length, { void Function(int bytesSent)? onProgress, }) async { + final index = _writeCount; + _writeCount += 1; + final error = writeError; + if (error != null || index == failWriteAtIndex) { + throw error ?? StateError('write $index failed'); + } final bytes = []; await for (final chunk in data) { bytes.addAll(chunk); @@ -497,17 +538,21 @@ class FakeSftpSession implements SftpSession { @override Future makeDirectory(String path) async { madeDirectories.add(path); - tree[path] = []; + tree.putIfAbsent(path, () => []); } @override Future rename(String from, String to) async {} @override - Future delete(SftpEntry entry) async {} + Future delete(SftpEntry entry) async { + deletedPaths.add(entry.path); + } @override - Future close() async {} + Future close() async { + closeCount += 1; + } } class RecordingFileExport implements FileExport {