diff --git a/.agents/sessions/2026-04-07-moriremote-ipad-support/plan.md b/.agents/sessions/2026-04-07-moriremote-ipad-support/plan.md new file mode 100644 index 00000000..9e53c6a1 --- /dev/null +++ b/.agents/sessions/2026-04-07-moriremote-ipad-support/plan.md @@ -0,0 +1,174 @@ +# Plan: MoriRemote iPad Support + +## Overview + +Add a real iPad experience to MoriRemote by introducing adaptive layouts for regular-width environments while preserving the current compact iPhone flow. The app already declares iPad in its target settings, so the work focuses on navigation, layout, and connection-state UX rather than platform enablement. + +### Goals + +- Provide a native-feeling iPad layout for disconnected and connected MoriRemote states. +- Preserve the current iPhone UX for compact-width devices, including iPad when shown in compact width. +- Make server selection, tmux navigation, terminal access, and connection management work smoothly in regular-width layouts. +- Keep implementation aligned with existing SwiftUI architecture and `ShellCoordinator` behavior. + +### Success Criteria + +- [ ] MoriRemote builds for iPad simulator without regressions to iPhone build. +- [ ] On regular-width environments, MoriRemote uses a two-column `NavigationSplitView` while disconnected/connecting and a persistent two-pane workspace while connected. +- [ ] On regular-width while disconnected, the detail pane shows placeholder/help or selected-server detail. +- [ ] On regular-width while connecting, the detail pane shows the target server, connection progress, and recoverable failure messaging. +- [ ] On regular-width while connected, the left pane shows server status + tmux navigation and the right pane shows terminal content. +- [ ] In compact width, including iPhone and compact-width iPad, MoriRemote keeps the current stacked navigation and overlay drawer behavior. +- [ ] Add/edit server flows remain usable from all entry points on both iPhone and iPad, with sensible sheet sizing on regular-width devices. +- [ ] No stale terminal chrome remains after switch host, disconnect, or failed connection recovery. + +### Out of Scope + +- Stage Manager or multi-window support. +- Major redesign of terminal rendering or tmux command semantics. +- Hardware-keyboard shortcut redesign beyond ensuring the existing UI still works. +- App Store metadata or screenshot work. +- Drag-and-drop or user-customizable multi-column arrangements. +- New automated UI-test infrastructure beyond any existing lightweight state/build coverage already present in the repo. + +## Technical Approach + +Adaptive rule: +- Regular-width behavior applies whenever the app is in a regular horizontal size class. +- Compact-width behavior applies whenever the app is in a compact horizontal size class, regardless of device idiom. +- This pass does not add special Stage Manager or multi-window handling beyond those size-class rules. + +State model: +- **Disconnected browsing**: no active SSH session; regular-width UI may hold a selected server for browsing. +- **Connecting(targetServer)**: connection has been initiated for a specific server; `ShellCoordinator` records that server as the authoritative connection target immediately when connect starts. +- **Connected(activeServer)**: SSH is established and the app is transitioning into or already showing the terminal workspace for that server. +- **Shell(activeServer)**: terminal is open and tmux/sidebar interactions are active. +- **Connection failed(targetServer, error)**: connection attempt ended in failure; the disconnected regular-width detail should continue showing the same target server with failure messaging until the user retries, changes selection, edits the server, or starts another successful connection. + +Ownership model: +- `ShellCoordinator.activeServer` becomes authoritative at connection initiation and remains the source of truth for the in-flight or active connection target. +- A lightweight regular-width UI selection tracks the currently browsed server while disconnected; it never owns the underlying SSH session. +- When a connection attempt fails, disconnected selection should remain on the same server where practical so the user can retry or edit without losing context. +- Failure UI must clear deterministically on reselection, retry, successful connection, or server edit. + +Single-flight connection behavior: +- While in `Connecting(targetServer)`, MoriRemote treats connection as single-flight. +- A second connect action is disabled until the current attempt succeeds or fails. +- Changing disconnected selection during connection is allowed only as a browsing action and must not retarget the in-flight connection. +- `Switch Host` during an in-flight connection returns the UI to disconnected browsing and abandons the current attempt using existing coordinator disconnect/reset behavior. + +On regular width: +- **Disconnected browsing / failed connection:** a `NavigationSplitView` with the server list in the sidebar and a detail pane that shows empty-state help or a selected-server summary with connect/retry/edit actions. +- **Connecting:** keep the same split structure, but the detail pane switches to a progress-focused state for `targetServer`, with failure messaging surfaced if connection fails. +- **Connected / shell:** a persistent workspace with one navigation pane containing server actions and tmux state, and one detail pane containing the terminal. + +On compact width: +- Keep the current root-state switching behavior: server list before connection, terminal screen after connection. +- Keep the existing slide-over tmux drawer in `TerminalScreen`. +- Compact-width iPad follows the same behavior as iPhone. + +Switch-host / disconnect behavior: +- **Disconnect** from the regular-width connected workspace tears down the active connection and returns immediately to the disconnected split view with the last browsed/active server still selected if possible. +- **Switch Host** from the regular-width connected workspace is treated as “leave current terminal and return to the disconnected split view,” reusing the selected server detail model so the user can choose another server without stale terminal UI remaining visible. +- This pass will not add a confirmation dialog unless existing behavior already requires one; it preserves current semantics while improving presentation. + +Runtime size changes: +- Switching between regular and compact layouts must not drop the active SSH/tmux connection. +- If an active server is connected, layout changes only affect presentation; the same connection remains active. +- Presentation-specific UI state (for example phone drawer open/closed, selected split-view column visibility, transient sheet placement) may reset when crossing layout classes. +- Recreating the terminal container view during layout changes is acceptable as long as the underlying session remains active and renderer wiring/focus are restored correctly. + +### Components + +- **Adaptive root view**: Decides whether to render compact phone navigation or regular-width split/workspace navigation. +- **Regular-width selection model**: Stores disconnected split-view server selection without duplicating connection authority. +- **Stable terminal/session host**: Keeps shell lifecycle and renderer wiring centralized so root-layout changes do not imply session loss. +- **Reusable server list content**: Extract current server list UI so it can be embedded in both a phone `NavigationStack` and an iPad split view. +- **Disconnected detail content**: Regular-width detail views for empty, selected, connecting, and failure states. +- **Connected iPad workspace**: A regular-width container with a persistent tmux/server navigation pane and terminal detail pane. +- **Sidebar presentation adapters**: Reuse tmux sidebar content while making dismiss controls conditional on overlay vs persistent presentation. +- **Presentation polish**: Tune sheet sizing, placeholder copy, and sizing behavior for iPad readability. + +## Implementation Phases + +### Phase 1: Adaptive navigation and state foundation + +1. Extract reusable server list content from `MoriRemote/MoriRemote/Views/ServerListView.swift` so it can be embedded in both compact and regular-width containers. +2. Introduce a lightweight regular-width disconnected selection model under `MoriRemote/MoriRemote/Views/` and update `MoriRemote/MoriRemote/MoriRemoteApp.swift` so regular width uses `NavigationSplitView` for disconnected/connecting states while compact width preserves the current root switching flow. +3. Establish a stable terminal/session host boundary early, so later regular-width workspace and runtime size-class transitions reuse the same shell lifecycle rules instead of coupling session continuity to a single presentation view. +4. Add regular-width detail content that covers: no servers yet, selected-server summary with connect action, connecting progress, and connection failure messaging with deterministic clearing rules. +5. Ensure runtime transitions between compact and regular width preserve connection state and keep disconnected selection resilient to split-view collapse/expansion. + +### Phase 2: Connected iPad workspace + +1. Refactor `MoriRemote/MoriRemote/Views/TerminalScreen.swift` so shell lifecycle/rendering stays centralized while presentation adapts between compact drawer mode and regular-width persistent-sidebar mode. +2. Reuse/adapt tmux navigation from `MoriRemote/MoriRemote/Views/TmuxSidebarView.swift` and related sidebar components so the regular-width connected workspace uses a persistent left pane containing server actions, tmux sessions/windows, switch-host, and disconnect controls. +3. Update sidebar/header components that currently assume dismiss-only overlay behavior, making close buttons and callbacks conditional for persistent iPad presentation. +4. Define connected-state transition behavior after disconnect, switch host, or interrupted in-flight connection so the UI returns cleanly to the regular-width disconnected split view without stale terminal chrome, while preserving the selected server where appropriate. + +### Phase 3: iPad polish, docs, and verification + +1. Adjust `MoriRemote/MoriRemote/Views/ServerFormView.swift` sheet sizing/presentation for regular-width iPad while preserving compact presentation. +2. Verify and adapt add/edit server invocation, save, dismiss, and list refresh behavior from both disconnected regular-width split view and compact flow. +3. Add/update localized strings in `MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings` and `MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings` for any new placeholder/help/error copy. +4. Update `CHANGELOG.md` and, if platform support or usage description changes materially, `README.md` to reflect improved iPad support. +5. Run MoriRemote build verification on both an iPad simulator and an iPhone simulator, then fix any build/layout issues surfaced by compilation. +6. If there are existing lightweight state/build tests relevant to MoriRemote, update them; otherwise explicitly treat this change as manual-verification-only due to current test infrastructure. + +## Testing Strategy + +- Build MoriRemote for an iPad simulator destination. +- Build MoriRemote for an iPhone simulator destination to catch compact-layout regressions. +- Manually verify disconnected regular-width iPad state: empty state, populated server list, selected-server detail, add/edit sheet presentation. +- Manually verify connecting regular-width iPad state: selected target server shown, progress visible, failures surface cleanly, second connect is blocked, selection changes do not retarget the in-flight attempt, and retry/return flows work. +- Manually verify connected regular-width iPad state: terminal visible, persistent tmux/server pane visible, switch-host/disconnect flows accessible, and UI returns to disconnected split view after disconnect. +- Manually verify compact iPhone / compact-width iPad state still uses overlay sidebar and can connect/disconnect. +- Explicitly verify live size-class transitions: connected while rotating iPad, disconnecting after transition, starting a connection in compact and finishing in regular, starting in regular and collapsing to compact, and any presented sheet/drawer during transition. +- Explicitly verify terminal focus and input behavior after layout changes: software keyboard activation, accessory bar visibility, and tmux interaction still function. +- Sanity-check hardware keyboard behavior to ensure the persistent iPad sidebar does not break existing key input flow. +- Verify server-list mutation edge cases: editing the selected disconnected server, editing the currently connected server, deleting the selected server while disconnected, and deleting the previously active server after disconnect/switch-host. +- Verify failure-state lifetime: error copy clears on reselection, retry, successful connection, and server edit, and never appears for the wrong server. + +## Risks + +| Risk | Impact | Mitigation | +| ---- | ------ | ---------- | +| Root-view refactor could break current iPhone navigation | High | Keep compact path close to existing `ServerListView`/`TerminalScreen` behavior and build for iPhone simulator after each phase | +| Persistent iPad sidebar may conflict with terminal focus/keyboard behavior | High | Limit shell lifecycle changes; keep renderer setup in one place and only change presentation layer | +| Existing sidebar components may assume overlay dismissal affordances | Medium | Isolate persistent-vs-overlay behavior behind small view adapters and conditional controls | +| Selection/state handling could drift across connected/disconnected or compact/regular transitions | High | Keep connection authority in `ShellCoordinator`, keep disconnected browsing selection lightweight and presentation-agnostic, and manually verify rotation/size-class transitions | +| `NavigationSplitView` collapse/visibility behavior may vary across iPad sizes/orientations | Medium | Keep selected-server/detail state independent of split-view presentation so collapsing columns does not lose the current intent | +| Failure state could stick to the wrong server after retries or edits | Medium | Tie failure UI to the target server and clear it deterministically on reselection, retry, success, or edit | +| New iPad placeholder and guidance copy could miss localization/documentation requirements | Medium | Route new strings through localization files and update user-facing docs noted in repo guidance | + +## Open Questions + +- [x] Use a regular-width split view while disconnected and a persistent two-pane workspace while connected. +- [x] Preserve compact-width iPhone behavior as the baseline, including compact-width iPad. +- [x] Preserve the underlying SSH/tmux connection across layout changes even if presentation state resets. +- [x] Treat switch-host as a transition back to disconnected browsing rather than an in-place multi-host workflow. +- [x] Treat connection attempts as single-flight and keep `activeServer` authoritative from connect initiation. + +## Review Feedback + +### Round 1 +- Clarified the exact regular-width information architecture for disconnected and connected states. +- Added explicit behavior for compact-width iPad and runtime size-class transitions. +- Expanded testing around terminal focus/input and connected-state flows. +- Added README/doc sync and selection/state regression coverage. + +### Round 2 +- Added an explicit regular-width disconnected selection model and split-view collapse mitigation. +- Defined connecting-state detail behavior and switch-host/disconnect transitions. +- Expanded add/edit workflow coverage and live size-class transition tests. +- Clarified terminal recreation vs underlying session preservation during layout changes. + +### Round 3 +- Added an explicit state model for connecting/failed states and failure clearing rules. +- Defined single-flight connection behavior and in-flight switch-host handling. +- Moved stable terminal/session-host extraction into Phase 1 because layout continuity depends on it. +- Added mutation/failure-lifetime verification and clarified the adaptive size-class rule. + +## Final Status + +Completed. MoriRemote now has an adaptive iPad experience with a regular-width split browser while disconnected, a persistent two-pane connected workspace, iPad-tuned server form presentation, localized new UI copy, and verified iPad/iPhone simulator builds. Remaining follow-up is optional cleanup only; no implementation-phase blockers remain. diff --git a/.agents/sessions/2026-04-07-moriremote-ipad-support/tasks.md b/.agents/sessions/2026-04-07-moriremote-ipad-support/tasks.md new file mode 100644 index 00000000..6a6876ce --- /dev/null +++ b/.agents/sessions/2026-04-07-moriremote-ipad-support/tasks.md @@ -0,0 +1,22 @@ +# Tasks: MoriRemote iPad Support + +## Phase 1: Adaptive navigation and state foundation +- [x] 1.1 Extract reusable server list content from `MoriRemote/MoriRemote/Views/ServerListView.swift` +- [x] 1.2 Add a regular-width disconnected selection model and adaptive root view in `MoriRemote/MoriRemote/MoriRemoteApp.swift` / new `Views/*` +- [x] 1.3 Establish a stable terminal/session host boundary for layout changes +- [x] 1.4 Add regular-width detail content for empty/selected/connecting/failure states +- [x] 1.5 Preserve connection state across compact/regular transitions and keep selection resilient to split-view collapse + +## Phase 2: Connected iPad workspace +- [x] 2.1 Refactor `MoriRemote/MoriRemote/Views/TerminalScreen.swift` for compact drawer vs regular persistent-sidebar presentation +- [x] 2.2 Adapt `TmuxSidebarView` and related sidebar components for regular-width persistent navigation +- [x] 2.3 Update dismiss-only sidebar/header assumptions for persistent iPad presentation +- [x] 2.4 Return cleanly to disconnected split view after disconnect / switch host / interrupted connect + +## Phase 3: iPad polish, docs, and verification +- [x] 3.1 Adjust `MoriRemote/MoriRemote/Views/ServerFormView.swift` iPad sheet sizing/presentation +- [x] 3.2 Verify/fix add-edit invocation, save, dismiss, and list refresh in compact + regular flows +- [x] 3.3 Add/update localization for any new MoriRemote strings +- [x] 3.4 Update `CHANGELOG.md` and `README.md` if needed +- [x] 3.5 Build MoriRemote for iPad + iPhone simulators and fix issues +- [x] 3.6 Update any existing lightweight tests if applicable, otherwise document manual-only verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3fe55e..5e3d4106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ✨ Features + +- **MoriRemote**: add adaptive iPad layouts with split-view server browsing while disconnected and a persistent two-pane workspace while connected +- **MoriRemote**: polish iPhone and iPad UI to follow the shared Mac-first `DESIGN.md` language with denser server rows, flatter tmux sidebars, compact terminal chrome, and normalized dark semantic styling +- **MoriRemote**: restyle the terminal accessory bar and key customization sheet with compact Mori tokens, semantic accent usage, and localized tmux actions +- **MoriRemote**: polish terminal connection microstates with richer iPad connection/failure detail states and a calmer in-terminal shell preparation overlay + +### 🐛 Bug Fixes + +- **MoriRemote**: make the regular-width terminal sidebar collapsible again and replace the crashing iPad keyboard-accessory tmux menu with a stable confirmation dialog +- **MoriRemote**: move compact terminal navigation into the accessory row by adding a back control beside tmux, keeping the terminal viewport free of extra chrome +- **MoriRemote**: harden terminal session lifecycle handling so disconnects, host switches, stale shell callbacks, and accessory-bar reuse no longer race into broken shell/tmux state +- **MoriRemote**: defer accessory-bar navigation and tmux/customization presentation until after the keyboard responder cycle, preventing crashes when tapping Back or tmux actions +- **MoriRemote**: preserve reconnect reliability after Back/disconnect by preventing stale disconnect tasks from overwriting a newer SSH connection attempt +- **MoriRemote**: stop the server list from hanging in "Connecting…" forever by surfacing missing-password and SSH timeout failures as explicit errors + ### ♻️ Changes - Remove legacy workflow-status and sidebar-mode code paths after the unified sidebar redesign, including the `mori status` CLI command and manual sidebar status controls diff --git a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj index d2dc1333..39d957a2 100644 --- a/MoriRemote/MoriRemote.xcodeproj/project.pbxproj +++ b/MoriRemote/MoriRemote.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 57768C47189EF8BE03AD3106 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = CB7E8BE74D0419BD18CEA5E3 /* SwiftTerm */; }; 57A0B148D1B8D23CA120481C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 495654252F6CE455BE0201B3 /* Assets.xcassets */; }; 58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6666C37E7773D0C60B3D16BA /* ServerListView.swift */; }; + 58F100000000000000000001 /* RegularWidthServerBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F100000000000000000002 /* RegularWidthServerBrowserView.swift */; }; + 58F100000000000000000003 /* TerminalSessionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F100000000000000000004 /* TerminalSessionHost.swift */; }; 593DE6D8BD420A29CCDB02CD /* KeyAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F0FEA59D35705F0BCD383 /* KeyAction.swift */; }; 5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */; }; 5D0A706E5CC15CA815D2205C /* MoriSSH in Frameworks */ = {isa = PBXBuildFile; productRef = E4A46D15917AB2D06DABB5BF /* MoriSSH */; }; @@ -53,6 +55,8 @@ 493C27506159F574E156F96C /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; 495654252F6CE455BE0201B3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 4A727118EE1EEE5BDC793991 /* TmuxWindowRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxWindowRow.swift; sourceTree = ""; }; + 58F100000000000000000002 /* RegularWidthServerBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularWidthServerBrowserView.swift; sourceTree = ""; }; + 58F100000000000000000004 /* TerminalSessionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSessionHost.swift; sourceTree = ""; }; 6666C37E7773D0C60B3D16BA /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = ""; }; 6C511C1314958A8D89FC53C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 879D7ABF80A185D8F5020407 /* TmuxBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxBarView.swift; sourceTree = ""; }; @@ -156,10 +160,12 @@ isa = PBXGroup; children = ( 4E3BB9B3445110B4B4503A85 /* Sidebar */, + 58F100000000000000000002 /* RegularWidthServerBrowserView.swift */, 93C9BF50025C03B0BCF8E77A /* ServerFormView.swift */, 6666C37E7773D0C60B3D16BA /* ServerListView.swift */, 348894A19970D2C348B4A692 /* SidebarContainer.swift */, 11EBAEEBBFE6A6FF22BFC722 /* TerminalScreen.swift */, + 58F100000000000000000004 /* TerminalSessionHost.swift */, A15134BC85B9E6519FF7F2A9 /* TmuxSidebarView.swift */, ); path = Views; @@ -274,6 +280,7 @@ 376EEC1B30EE8565C4B085D1 /* MoriRemoteApp.swift in Sources */, 97B5FB0C7E80E60AF0D59B0E /* Server.swift in Sources */, DE5E3473363E4A48BB9FAE83 /* ServerCardView.swift in Sources */, + 58F100000000000000000001 /* RegularWidthServerBrowserView.swift in Sources */, 3E10DC5EF41F3B0DBD7E18E0 /* ServerFormView.swift in Sources */, 58C6389AD3DF6D861F49D1DE /* ServerListView.swift in Sources */, 949356F283B52C6F3AFC7B06 /* ServerStore.swift in Sources */, @@ -281,6 +288,7 @@ F005EC1ECFC968DF06655D7E /* SidebarContainer.swift in Sources */, F8BC0A57AF5F0A266F023AB0 /* TerminalAccessoryBar.swift in Sources */, 5ACA156F8A500931E29BAD3E /* TerminalScreen.swift in Sources */, + 58F100000000000000000003 /* TerminalSessionHost.swift in Sources */, 96C8DDDEA3B929ED016AF50D /* TerminalView.swift in Sources */, 84BCE7CB7E779A71350C7FB7 /* Theme.swift in Sources */, D2F9125D254E9980272FA1F7 /* TmuxBarView.swift in Sources */, diff --git a/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift b/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift index 60ac7a26..3edea1b9 100644 --- a/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift +++ b/MoriRemote/MoriRemote/Accessories/KeyBarCustomizeView.swift @@ -2,24 +2,34 @@ import SwiftUI /// Bottom sheet for customizing the keyboard accessory key bar. -/// Users toggle keys on/off. Changes apply live to the key bar. struct KeyBarCustomizeView: View { - /// Direct reference to the UIKit key bar — mutations apply immediately. let keyBar: KeyBarView @Environment(\.dismiss) private var dismiss - /// Local copy of layout for SwiftUI reactivity. @State private var layout: [KeyAction] = [] var body: some View { NavigationStack { List { - Section("Active Keys") { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Customize Keys")) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(String(localized: "Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close.")) + .font(.system(size: 13)) + .foregroundStyle(Theme.textSecondary) + } + .listRowBackground(Color.clear) + } + + Section(String(localized: "Active Keys")) { let activeKeys = layout.filter { $0 != .divider } if activeKeys.isEmpty { - Text("No keys added") - .foregroundStyle(.secondary) - .font(.subheadline) + Text(String(localized: "No keys added")) + .foregroundStyle(Theme.textSecondary) + .font(.system(size: 13)) } else { ForEach(activeKeys, id: \.self) { action in activeKeyRow(action) @@ -38,7 +48,7 @@ struct KeyBarCustomizeView: View { } ForEach(KeyAction.Category.allCases, id: \.self) { category in - Section(category.rawValue) { + Section(category.localizedTitle) { let actions = KeyAction.actions(for: category) ForEach(actions, id: \.self) { action in keyToggleRow(action) @@ -46,20 +56,23 @@ struct KeyBarCustomizeView: View { } } } - .navigationTitle("Customize Keys") + .scrollContentBackground(.hidden) + .background(Theme.bg) + .navigationTitle(String(localized: "Customize Keys")) .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarLeading) { EditButton() } ToolbarItem(placement: .topBarTrailing) { - Button("Reset") { + Button(String(localized: "Reset")) { applyLayout(KeyAction.defaultLayout) } .foregroundStyle(Theme.destructive) } ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } + Button(String(localized: "Done")) { dismiss() } .fontWeight(.semibold) } } @@ -76,26 +89,20 @@ struct KeyBarCustomizeView: View { KeyBarLayout.save(newLayout) } - // MARK: - Active Key Row - private func activeKeyRow(_ action: KeyAction) -> some View { - HStack { - Text(action.label) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(keyColor(action)) - .frame(width: 48, height: 28) - .background(keyBackground(action), in: RoundedRectangle(cornerRadius: 5)) + HStack(spacing: 12) { + keyPreview(action, width: 52, height: 28) - Text(actionDescription(action)) - .font(.subheadline) - .foregroundStyle(.secondary) + Text(action.localizedDescription) + .font(.system(size: 13)) + .foregroundStyle(Theme.textSecondary) Spacer() } + .padding(.vertical, 2) + .listRowBackground(Theme.mutedSurface) } - // MARK: - Toggle Row - private func keyToggleRow(_ action: KeyAction) -> some View { let isInBar = layout.contains(action) return Button { @@ -107,65 +114,93 @@ struct KeyBarCustomizeView: View { } applyLayout(newLayout) } label: { - HStack { - Text(action.label) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundStyle(keyColor(action)) - .frame(width: 60, height: 30) - .background(keyBackground(action), in: RoundedRectangle(cornerRadius: 6)) + HStack(spacing: 12) { + keyPreview(action, width: 62, height: 30) - Text(actionDescription(action)) - .font(.subheadline) - .foregroundStyle(.secondary) + Text(action.localizedDescription) + .font(.system(size: 13)) + .foregroundStyle(Theme.textSecondary) Spacer() Image(systemName: isInBar ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isInBar ? Theme.accent : .secondary) + .foregroundStyle(isInBar ? Theme.accent : Theme.textTertiary) } + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .listRowBackground(Theme.mutedSurface) } - // MARK: - Helpers + private func keyPreview(_ action: KeyAction, width: CGFloat, height: CGFloat) -> some View { + Text(action.label) + .font(.system(size: action.isSpecial ? 10 : 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(keyColor(action)) + .frame(width: width, height: height) + .background(keyBackground(action), in: RoundedRectangle(cornerRadius: 7)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(keyBorder(action), lineWidth: 1) + ) + } private func keyColor(_ action: KeyAction) -> Color { - if action.isTmux { return Color(Theme.accent) } - if action.isSpecial { return .white.opacity(0.55) } - return .white + if action.isTmux { return Theme.accent } + if action.isSpecial { return Theme.textSecondary } + return Theme.textPrimary } private func keyBackground(_ action: KeyAction) -> Color { - if action.isTmux { return Color(Theme.accent).opacity(0.1) } - if action.isSpecial { return Color(red: 0.118, green: 0.118, blue: 0.149) } - return Color(red: 0.165, green: 0.165, blue: 0.196) + if action.isTmux { return Theme.accentSoft } + if action.isSpecial { return Theme.elevatedBg } + return Theme.mutedSurface } - private func actionDescription(_ action: KeyAction) -> String { - switch action { - case .esc: return "Escape key" - case .ctrl: return "Control modifier (sticky)" - case .alt: return "Alt/Meta modifier" - case .tab: return "Tab key" - case .tmuxPrefix: return "Tmux prefix (Ctrl+B)" - case .tmuxNewTab: return "New tab (⌘T)" - case .tmuxClosePane: return "Close pane (⌘W)" - case .tmuxNextTab: return "Next tab (⌘⇧])" - case .tmuxPrevTab: return "Previous tab (⌘⇧[)" - case .tmuxSplitH: return "Split right (⌘D)" - case .tmuxSplitV: return "Split down (⌘⇧D)" - case .tmuxNextPane: return "Next pane (⌘])" - case .tmuxPrevPane: return "Previous pane (⌘[)" - case .tmuxZoom: return "Toggle zoom (⌘⇧↩)" - case .tmuxDetach: return "Detach session" - case .left: return "Arrow left (auto-repeat)" - case .down: return "Arrow down (auto-repeat)" - case .up: return "Arrow up (auto-repeat)" - case .right: return "Arrow right (auto-repeat)" - case .home: return "Home key" - case .end: return "End key" - case .pageUp: return "Page up" - case .pageDown: return "Page down" - default: return "Send '\(action.label)'" + private func keyBorder(_ action: KeyAction) -> Color { + if action.isTmux { return Theme.accentBorder } + return Theme.cardBorder + } +} + +private extension KeyAction.Category { + var localizedTitle: LocalizedStringKey { + switch self { + case .modifiers: return "Modifiers" + case .symbols: return "Symbols" + case .navigation: return "Navigation" + case .functionKeys: return "Function Keys" + case .tmux: return "Tmux Shortcuts" + } + } +} + +private extension KeyAction { + var localizedDescription: String { + switch self { + case .esc: return String(localized: "Escape key") + case .ctrl: return String(localized: "Control modifier (sticky)") + case .alt: return String(localized: "Alt/Meta modifier") + case .tab: return String(localized: "Tab key") + case .tmuxPrefix: return String(localized: "Tmux prefix (Ctrl+B)") + case .tmuxNewTab: return String(localized: "New tab (⌘T)") + case .tmuxClosePane: return String(localized: "Close pane (⌘W)") + case .tmuxNextTab: return String(localized: "Next tab (⌘⇧])") + case .tmuxPrevTab: return String(localized: "Previous tab (⌘⇧[)") + case .tmuxSplitH: return String(localized: "Split right (⌘D)") + case .tmuxSplitV: return String(localized: "Split down (⌘⇧D)") + case .tmuxNextPane: return String(localized: "Next pane (⌘])") + case .tmuxPrevPane: return String(localized: "Previous pane (⌘[)") + case .tmuxZoom: return String(localized: "Toggle zoom (⌘⇧↩)") + case .tmuxDetach: return String(localized: "Detach session") + case .left: return String(localized: "Arrow left (auto-repeat)") + case .down: return String(localized: "Arrow down (auto-repeat)") + case .up: return String(localized: "Arrow up (auto-repeat)") + case .right: return String(localized: "Arrow right (auto-repeat)") + case .home: return String(localized: "Home key") + case .end: return String(localized: "End key") + case .pageUp: return String(localized: "Page up") + case .pageDown: return String(localized: "Page down") + default: return String(localized: "Send key") + " ‘\(label)’" } } } diff --git a/MoriRemote/MoriRemote/Accessories/KeyBarView.swift b/MoriRemote/MoriRemote/Accessories/KeyBarView.swift index a0c9215a..245f4adf 100644 --- a/MoriRemote/MoriRemote/Accessories/KeyBarView.swift +++ b/MoriRemote/MoriRemote/Accessories/KeyBarView.swift @@ -2,52 +2,43 @@ import SwiftTerm import UIKit -/// Customizable horizontal key bar — bottom row of the terminal accessory. -/// -/// Renders a scrollable row of key buttons matching the design mockup. -/// Keys are grouped with thin divider separators. +/// Customizable horizontal key bar for terminal accessory actions. @MainActor final class KeyBarView: UIView { weak var terminalView: SwiftTerm.TerminalView? - - /// Called when the user taps the gear button to customize the key bar. + var onBackTapped: (() -> Void)? var onCustomizeTapped: (() -> Void)? - - /// Called when a tmux action is selected from the popup menu. + var onTmuxMenuTapped: (() -> Void)? var onTmuxAction: ((TmuxCommand) -> Void)? private let scrollView = UIScrollView() private let stackView = UIStackView() private var keyButtons: [UIView] = [] - /// Current layout. Setting this rebuilds the bar. var layout: [KeyAction] = KeyBarLayout.load() { didSet { rebuildKeys() } } - /// Tracks ctrl toggle state for visual feedback. private var ctrlActive = false - - // MARK: - Auto-repeat - private var repeatAction: KeyAction? private var repeatTask: Task? private var repeatTimer: Timer? - // MARK: - Colors (matching design tokens) - - private let keyBg = UIColor(red: 0.165, green: 0.165, blue: 0.196, alpha: 1) // #2a2a32 - private let keySpecialBg = UIColor(red: 0.118, green: 0.118, blue: 0.149, alpha: 1) // #1e1e26 - private let keyActiveBg = UIColor(red: 0.30, green: 0.85, blue: 0.75, alpha: 0.15) - private let accentColor = UIColor(red: 0.30, green: 0.85, blue: 0.75, alpha: 1) // teal - private let textColor = UIColor.white - private let textDim = UIColor.white.withAlphaComponent(0.55) - private let dividerColor = UIColor.white.withAlphaComponent(0.06) - private let tmuxKeyBg = UIColor(red: 0.30, green: 0.85, blue: 0.75, alpha: 0.08) - private let tmuxBorder = UIColor(red: 0.30, green: 0.85, blue: 0.75, alpha: 0.15) + private let barBg = UIColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1) + private let keyBg = UIColor.white.withAlphaComponent(0.05) + private let keySpecialBg = UIColor.white.withAlphaComponent(0.035) + private let keyActiveBg = UIColor.tintColor.withAlphaComponent(0.16) + private let accentColor = UIColor.tintColor + private let textColor = UIColor.white.withAlphaComponent(0.96) + private let textDim = UIColor.white.withAlphaComponent(0.62) + private let dividerColor = UIColor.white.withAlphaComponent(0.08) + private let tmuxKeyBg = UIColor.tintColor.withAlphaComponent(0.12) + private let tmuxBorder = UIColor.tintColor.withAlphaComponent(0.28) + private let keyBorder = UIColor.white.withAlphaComponent(0.08) - // MARK: - Init + private let fadeView = UIView() + private let fadeGradient = CAGradientLayer() override init(frame: CGRect) { super.init(frame: frame) @@ -57,27 +48,31 @@ final class KeyBarView: UIView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - /// Right-edge fade gradient to hint that the bar scrolls. - private let fadeView = UIView() - private let fadeGradient = CAGradientLayer() + deinit { + MainActor.assumeIsolated { + cancelAutoRepeat() + NotificationCenter.default.removeObserver(self) + } + } private func setup() { + backgroundColor = barBg + scrollView.showsHorizontalScrollIndicator = false scrollView.alwaysBounceHorizontal = true scrollView.delegate = self + scrollView.contentInsetAdjustmentBehavior = .never scrollView.translatesAutoresizingMaskIntoConstraints = false addSubview(scrollView) stackView.axis = .horizontal - stackView.spacing = 3 + stackView.spacing = 4 stackView.alignment = .center stackView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stackView) - // Right-edge fade hint fadeView.isUserInteractionEnabled = false fadeView.translatesAutoresizingMaskIntoConstraints = false - let barBg = UIColor(red: 0.102, green: 0.102, blue: 0.125, alpha: 1) fadeGradient.colors = [barBg.withAlphaComponent(0).cgColor, barBg.cgColor] fadeGradient.startPoint = CGPoint(x: 0, y: 0.5) fadeGradient.endPoint = CGPoint(x: 1, y: 0.5) @@ -91,8 +86,8 @@ final class KeyBarView: UIView { scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 4), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -4), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 6), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -6), stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), @@ -102,7 +97,6 @@ final class KeyBarView: UIView { fadeView.widthAnchor.constraint(equalToConstant: 28), ]) - // Listen for ctrl state changes from SwiftTerm NotificationCenter.default.addObserver( self, selector: #selector(ctrlModifierReset), @@ -118,18 +112,22 @@ final class KeyBarView: UIView { updateCtrlButton() } - // MARK: - Build Keys - private func rebuildKeys() { stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } keyButtons = [] - // Tmux menu button — first in bar + let back = makeBackButton() + stackView.addArrangedSubview(back) + keyButtons.append(back) + + let divBack = makeDivider() + stackView.addArrangedSubview(divBack) + keyButtons.append(divBack) + let tmux = makeTmuxMenuButton() stackView.addArrangedSubview(tmux) keyButtons.append(tmux) - // Divider after tmux button let div0 = makeDivider() stackView.addArrangedSubview(div0) keyButtons.append(div0) @@ -140,7 +138,6 @@ final class KeyBarView: UIView { stackView.addArrangedSubview(div) keyButtons.append(div) } else if action.isTmux { - // Skip individual tmux keys — they're in the popup now continue } else { let btn = makeKeyButton(for: action) @@ -149,88 +146,86 @@ final class KeyBarView: UIView { } } - // Keyboard dismiss button let divEnd = makeDivider() stackView.addArrangedSubview(divEnd) keyButtons.append(divEnd) - let kbdBtn = makeKeyboardDismissButton() - stackView.addArrangedSubview(kbdBtn) - keyButtons.append(kbdBtn) + let keyboardButton = makeKeyboardDismissButton() + stackView.addArrangedSubview(keyboardButton) + keyButtons.append(keyboardButton) - // Gear button at the end for customization let gear = makeGearButton() stackView.addArrangedSubview(gear) keyButtons.append(gear) } private func makeDivider() -> UIView { - let v = UIView() - v.backgroundColor = dividerColor - v.translatesAutoresizingMaskIntoConstraints = false + let view = UIView() + view.backgroundColor = dividerColor + view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - v.widthAnchor.constraint(equalToConstant: 1), - v.heightAnchor.constraint(equalToConstant: 22), + view.widthAnchor.constraint(equalToConstant: 1), + view.heightAnchor.constraint(equalToConstant: 20), ]) - return v + return view } private func makeKeyButton(for action: KeyAction) -> UIButton { - let btn = UIButton(type: .system) - btn.tag = action.hashValue - btn.layer.cornerRadius = 6 - btn.clipsToBounds = true + let button = UIButton(type: .system) + button.tag = action.hashValue + button.layer.cornerRadius = 7 + button.layer.borderWidth = 1 + button.layer.borderColor = keyBorder.cgColor + button.clipsToBounds = true + button.adjustsImageWhenHighlighted = false - // Sizing — compact to fit more keys on screen let isArrow = action.iconName != nil - let minW: CGFloat = isArrow ? 28 : 32 - btn.translatesAutoresizingMaskIntoConstraints = false - btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + let minWidth: CGFloat = isArrow ? 28 : 34 + button.translatesAutoresizingMaskIntoConstraints = false + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 7, bottom: 0, right: 7) NSLayoutConstraint.activate([ - btn.heightAnchor.constraint(equalToConstant: 30), - btn.widthAnchor.constraint(greaterThanOrEqualToConstant: minW), + button.heightAnchor.constraint(equalToConstant: 30), + button.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth), ]) - // Content if let iconName = action.iconName { - let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .medium) - let img = UIImage(systemName: iconName, withConfiguration: config) - btn.setImage(img, for: .normal) - btn.tintColor = textColor + let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + button.setImage(UIImage(systemName: iconName, withConfiguration: config), for: .normal) + button.tintColor = textColor } else { - btn.setTitle(action.label, for: .normal) - btn.titleLabel?.font = action.isSpecial || action.isTmux - ? .systemFont(ofSize: 11, weight: .semibold) - : .systemFont(ofSize: 12, weight: .medium) + button.setTitle(action.label, for: .normal) + button.titleLabel?.font = action.isSpecial || action.isTmux + ? .monospacedSystemFont(ofSize: 10, weight: .semibold) + : .monospacedSystemFont(ofSize: 11, weight: .medium) } - // Colors - applyStyle(to: btn, action: action, active: false) + applyStyle(to: button, action: action, active: false) + objc_setAssociatedObject(button, &KeyBarView.actionKey, action, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - // Store action as associated object - objc_setAssociatedObject(btn, &KeyBarView.actionKey, action, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + button.addTarget(self, action: #selector(keyDown(_:)), for: .touchDown) + button.addTarget(self, action: #selector(keyUp(_:)), for: .touchUpInside) + button.addTarget(self, action: #selector(keyUp(_:)), for: .touchUpOutside) + button.addTarget(self, action: #selector(keyUp(_:)), for: .touchCancel) - // Actions - btn.addTarget(self, action: #selector(keyDown(_:)), for: .touchDown) - btn.addTarget(self, action: #selector(keyUp(_:)), for: .touchUpInside) - btn.addTarget(self, action: #selector(keyUp(_:)), for: .touchUpOutside) - btn.addTarget(self, action: #selector(keyUp(_:)), for: .touchCancel) - - return btn + return button } - private func applyStyle(to btn: UIButton, action: KeyAction, active: Bool) { + private func applyStyle(to button: UIButton, action: KeyAction, active: Bool) { if action.isTmux { - btn.backgroundColor = active ? accentColor.withAlphaComponent(0.2) : tmuxKeyBg - btn.setTitleColor(accentColor, for: .normal) - btn.layer.borderWidth = 1 - btn.layer.borderColor = tmuxBorder.cgColor + button.backgroundColor = active ? accentColor.withAlphaComponent(0.18) : tmuxKeyBg + button.setTitleColor(accentColor, for: .normal) + button.tintColor = accentColor + button.layer.borderColor = tmuxBorder.cgColor } else if action.isSpecial { - btn.backgroundColor = active ? keyActiveBg : keySpecialBg - btn.setTitleColor(active ? accentColor : textDim, for: .normal) + button.backgroundColor = active ? keyActiveBg : keySpecialBg + button.setTitleColor(active ? accentColor : textDim, for: .normal) + button.tintColor = active ? accentColor : textDim + button.layer.borderColor = (active ? tmuxBorder : keyBorder).cgColor } else { - btn.backgroundColor = active ? keyActiveBg : keyBg - btn.setTitleColor(textColor, for: .normal) + button.backgroundColor = active ? keyActiveBg : keyBg + button.setTitleColor(textColor, for: .normal) + button.tintColor = textColor + button.layer.borderColor = (active ? tmuxBorder : keyBorder).cgColor } } @@ -240,69 +235,77 @@ final class KeyBarView: UIView { objc_getAssociatedObject(button, &KeyBarView.actionKey) as? KeyAction } - private var tmuxMenuButton: UIButton? + private func makeBackButton() -> UIButton { + let button = UIButton(type: .system) + let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + button.setImage(UIImage(systemName: "chevron.backward", withConfiguration: config), for: .normal) + button.tintColor = textDim + button.backgroundColor = keySpecialBg + button.layer.cornerRadius = 7 + button.layer.borderWidth = 1 + button.layer.borderColor = keyBorder.cgColor + button.clipsToBounds = true + button.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 30), + button.widthAnchor.constraint(equalToConstant: 30), + ]) + return button + } + + @objc private func backTapped() { + UIDevice.current.playInputClick() + dismissKeyboardForDeferredUITransition() + DispatchQueue.main.async { [weak self] in + self?.onBackTapped?() + } + } private func makeTmuxMenuButton() -> UIButton { - let btn = UIButton(type: .system) - btn.setTitle("tmux", for: .normal) - btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .bold) - btn.setTitleColor(accentColor, for: .normal) - btn.backgroundColor = tmuxKeyBg - btn.layer.cornerRadius = 6 - btn.layer.borderWidth = 1 - btn.layer.borderColor = tmuxBorder.cgColor - btn.clipsToBounds = true - btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - btn.translatesAutoresizingMaskIntoConstraints = false + let button = UIButton(type: .system) + button.setTitle(String(localized: "tmux"), for: .normal) + button.titleLabel?.font = .monospacedSystemFont(ofSize: 10, weight: .bold) + button.setTitleColor(accentColor, for: .normal) + button.backgroundColor = tmuxKeyBg + button.layer.cornerRadius = 7 + button.layer.borderWidth = 1 + button.layer.borderColor = tmuxBorder.cgColor + button.clipsToBounds = true + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 9, bottom: 0, right: 9) + button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - btn.heightAnchor.constraint(equalToConstant: 30), + button.heightAnchor.constraint(equalToConstant: 30), ]) - - btn.menu = buildTmuxMenu() - btn.showsMenuAsPrimaryAction = true - tmuxMenuButton = btn - return btn + button.addTarget(self, action: #selector(tmuxMenuTapped), for: .touchUpInside) + return button } - private func buildTmuxMenu() -> UIMenu { - let items: [(TmuxCommand, String, String)] = [ - (.newWindow, "New Tab", "plus.square"), - (.nextWindow, "Next Tab", "arrow.right.square"), - (.prevWindow, "Previous Tab", "arrow.left.square"), - (.splitRight, "Split Right", "rectangle.split.2x1"), - (.splitDown, "Split Down", "rectangle.split.1x2"), - (.nextPane, "Next Pane", "arrow.right.circle"), - (.prevPane, "Previous Pane", "arrow.left.circle"), - (.toggleZoom, "Toggle Zoom", "arrow.up.left.and.arrow.down.right"), - (.closePane, "Close Pane", "xmark.square"), - (.detach, "Detach", "eject"), - ] - - let actions = items.map { cmd, title, icon in - UIAction(title: title, image: UIImage(systemName: icon)) { [weak self] _ in - self?.onTmuxAction?(cmd) - } + @objc private func tmuxMenuTapped() { + UIDevice.current.playInputClick() + dismissKeyboardForDeferredUITransition() + DispatchQueue.main.async { [weak self] in + self?.onTmuxMenuTapped?() } - - return UIMenu(title: "", children: actions) } private func makeKeyboardDismissButton() -> UIButton { - let btn = UIButton(type: .system) - let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .medium) - let img = UIImage(systemName: "keyboard.chevron.compact.down", withConfiguration: config) - btn.setImage(img, for: .normal) - btn.tintColor = textDim - btn.backgroundColor = keySpecialBg - btn.layer.cornerRadius = 6 - btn.clipsToBounds = true - btn.addTarget(self, action: #selector(keyboardDismissTapped), for: .touchUpInside) - btn.translatesAutoresizingMaskIntoConstraints = false + let button = UIButton(type: .system) + let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + button.setImage(UIImage(systemName: "keyboard.chevron.compact.down", withConfiguration: config), for: .normal) + button.tintColor = textDim + button.backgroundColor = keySpecialBg + button.layer.cornerRadius = 7 + button.layer.borderWidth = 1 + button.layer.borderColor = keyBorder.cgColor + button.clipsToBounds = true + button.addTarget(self, action: #selector(keyboardDismissTapped), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - btn.heightAnchor.constraint(equalToConstant: 32), - btn.widthAnchor.constraint(equalToConstant: 36), + button.heightAnchor.constraint(equalToConstant: 30), + button.widthAnchor.constraint(equalToConstant: 34), ]) - return btn + return button } @objc private func keyboardDismissTapped() { @@ -311,33 +314,41 @@ final class KeyBarView: UIView { } private func makeGearButton() -> UIButton { - let btn = UIButton(type: .system) - let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .medium) - let img = UIImage(systemName: "gearshape", withConfiguration: config) - btn.setImage(img, for: .normal) - btn.tintColor = textDim - btn.backgroundColor = keySpecialBg - btn.layer.cornerRadius = 6 - btn.clipsToBounds = true - btn.addTarget(self, action: #selector(gearTapped), for: .touchUpInside) - btn.translatesAutoresizingMaskIntoConstraints = false + let button = UIButton(type: .system) + let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + button.setImage(UIImage(systemName: "slider.horizontal.3", withConfiguration: config), for: .normal) + button.tintColor = textDim + button.backgroundColor = keySpecialBg + button.layer.cornerRadius = 7 + button.layer.borderWidth = 1 + button.layer.borderColor = keyBorder.cgColor + button.clipsToBounds = true + button.addTarget(self, action: #selector(gearTapped), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - btn.heightAnchor.constraint(equalToConstant: 32), - btn.widthAnchor.constraint(equalToConstant: 32), + button.heightAnchor.constraint(equalToConstant: 30), + button.widthAnchor.constraint(equalToConstant: 30), ]) - return btn + return button } @objc private func gearTapped() { - onCustomizeTapped?() + dismissKeyboardForDeferredUITransition() + DispatchQueue.main.async { [weak self] in + self?.onCustomizeTapped?() + } } - // MARK: - Key Events + private func dismissKeyboardForDeferredUITransition() { + _ = terminalView?.resignFirstResponder() + } @objc private func keyDown(_ sender: UIButton) { guard let action = action(for: sender) else { return } UIDevice.current.playInputClick() + applyStyle(to: sender, action: action, active: true) + if action.supportsAutoRepeat { startAutoRepeat(action) } else { @@ -346,39 +357,39 @@ final class KeyBarView: UIView { } @objc private func keyUp(_ sender: UIButton) { + if let action = action(for: sender), (!action.isToggle || !ctrlActive) { + applyStyle(to: sender, action: action, active: false) + } cancelAutoRepeat() } private func executeAction(_ action: KeyAction, button: UIButton? = nil) { - guard let tv = terminalView else { return } - let handled = action.execute(on: tv) + guard let terminalView else { return } + let handled = action.execute(on: terminalView) if !handled && action == .ctrl { - ctrlActive = tv.controlModifier + ctrlActive = terminalView.controlModifier updateCtrlButton() } } private func updateCtrlButton() { for view in stackView.arrangedSubviews { - guard let btn = view as? UIButton, - let action = action(for: btn), - action == .ctrl - else { continue } - applyStyle(to: btn, action: action, active: ctrlActive) + guard let button = view as? UIButton, + let action = action(for: button), + action == .ctrl else { continue } + applyStyle(to: button, action: action, active: ctrlActive) } } - // MARK: - Auto-repeat - private func startAutoRepeat(_ action: KeyAction) { cancelAutoRepeat() repeatAction = action executeAction(action) repeatTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 500_000_000) // 500ms initial delay + try? await Task.sleep(nanoseconds: 450_000_000) guard !Task.isCancelled else { return } - self.repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + self.repeatTimer = Timer.scheduledTimer(withTimeInterval: 0.075, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.executeAction(action) } @@ -394,8 +405,6 @@ final class KeyBarView: UIView { repeatAction = nil } - // MARK: - Layout - override func layoutSubviews() { super.layoutSubviews() fadeGradient.frame = fadeView.bounds @@ -412,8 +421,6 @@ final class KeyBarView: UIView { } } -// MARK: - UIScrollViewDelegate - extension KeyBarView: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { updateFadeVisibility() diff --git a/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift b/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift index 48a2292c..c324a6f8 100644 --- a/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift +++ b/MoriRemote/MoriRemote/Accessories/TerminalAccessoryBar.swift @@ -3,8 +3,7 @@ import SwiftTerm import UIKit /// Single-row input accessory view for the terminal keyboard. -/// -/// Contains the `KeyBarView` — customizable quick keys including the tmux menu button. +/// Contains the compact Mori-style quick key bar. @MainActor final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { @@ -14,18 +13,22 @@ final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { didSet { keyBar.terminalView = terminalView } } - /// Callback for tmux commands from the tmux popup menu in the key bar. + /// Callback for tmux commands from the key bar. var onTmuxCommand: ((TmuxCommand) -> Void)? + /// Called when the user taps the back button. + var onBackTapped: (() -> Void)? + + /// Called when the user taps the tmux menu button. + var onTmuxMenuTapped: (() -> Void)? + /// Called when the user taps the gear button to customize the key bar. var onCustomizeTapped: (() -> Void)? - // UIInputViewAudioFeedback var enableInputClicksWhenVisible: Bool { true } - private let barBg = UIColor(red: 0.102, green: 0.102, blue: 0.125, alpha: 1) // #1a1a20 - - // MARK: - Init + private let barBg = UIColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1) + private let topBorder = UIView() init() { super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 45), inputViewStyle: .keyboard) @@ -38,8 +41,7 @@ final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { required init?(coder: NSCoder) { fatalError() } private func setup() { - let topBorder = UIView() - topBorder.backgroundColor = UIColor.white.withAlphaComponent(0.06) + topBorder.backgroundColor = UIColor.white.withAlphaComponent(0.08) topBorder.translatesAutoresizingMaskIntoConstraints = false keyBar.translatesAutoresizingMaskIntoConstraints = false @@ -47,9 +49,15 @@ final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { addSubview(topBorder) addSubview(keyBar) + keyBar.onBackTapped = { [weak self] in + self?.onBackTapped?() + } keyBar.onCustomizeTapped = { [weak self] in self?.onCustomizeTapped?() } + keyBar.onTmuxMenuTapped = { [weak self] in + self?.onTmuxMenuTapped?() + } keyBar.onTmuxAction = { [weak self] cmd in self?.onTmuxCommand?(cmd) } @@ -68,10 +76,8 @@ final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { ]) } - // MARK: - Tmux State - func updateTmux(session: TmuxSession?, windows: [TmuxWindow]) { - // Kept for ShellCoordinator compatibility — no-op now that the pill is removed. + // Kept for ShellCoordinator compatibility. } override var intrinsicContentSize: CGSize { @@ -82,26 +88,20 @@ final class TerminalAccessoryBar: UIInputView, UIInputViewAudioFeedback { // MARK: - Tmux Command enum TmuxCommand: Sendable { - // Window/tab management case selectWindow(Int) case newWindow case nextWindow case prevWindow - - // Pane management case splitRight case splitDown case nextPane case prevPane case toggleZoom case closePane - - // Session case showSessionPicker case switchSession(String) case detach - /// The real tmux CLI command to execute via SSH exec channel. func shellCommand(session: String? = nil) -> String { switch self { case .selectWindow(let idx): return "tmux select-window -t :\(idx)" diff --git a/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift b/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift index 4465c94f..36d90ba6 100644 --- a/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift +++ b/MoriRemote/MoriRemote/Accessories/TmuxBarView.swift @@ -21,14 +21,11 @@ struct TmuxWindow: Equatable, Sendable, Identifiable { var id: String { "\(sessionName):\(index)" } - /// Short display path: last two path components or ~ for home. var shortPath: String { guard !path.isEmpty else { return "" } - // Replace home dir prefix with ~ let display = path.contains("/Users/") || path.contains("/home/") ? "~" + path.split(separator: "/").dropFirst(2).map { "/" + $0 }.joined() : path - // Show last 2 components let parts = display.split(separator: "/") if parts.count <= 2 { return display } return "…/" + parts.suffix(2).joined(separator: "/") @@ -43,36 +40,24 @@ struct TmuxWindow: Equatable, Sendable, Identifiable { } } -// MARK: - Delegate - @MainActor protocol TmuxBarDelegate: AnyObject { func tmuxBarDidTap() } -// MARK: - TmuxBarView - /// Compact status pill showing the active tmux session and window. -/// -/// Displays as a single tappable pill: "⬡ session › window_name". -/// Hidden when no tmux session is detected. @MainActor final class TmuxBarView: UIView { weak var delegate: TmuxBarDelegate? private let pillButton = UIButton(type: .system) - - // State private(set) var currentSession: TmuxSession? private(set) var windows: [TmuxWindow] = [] - // Colors - private let accentColor = UIColor(red: 0.30, green: 0.85, blue: 0.75, alpha: 1) - private let pillBg = UIColor(red: 0.118, green: 0.118, blue: 0.149, alpha: 1) - private let borderColor = UIColor.white.withAlphaComponent(0.06) - - // MARK: - Init + private let accentColor = UIColor.tintColor + private let pillBg = UIColor.tintColor.withAlphaComponent(0.12) + private let borderColor = UIColor.tintColor.withAlphaComponent(0.28) override init(frame: CGRect) { super.init(frame: frame) @@ -83,18 +68,12 @@ final class TmuxBarView: UIView { required init?(coder: NSCoder) { fatalError() } private func setup() { - // Bottom border - let border = UIView() - border.backgroundColor = borderColor - border.translatesAutoresizingMaskIntoConstraints = false - addSubview(border) - pillButton.backgroundColor = pillBg - pillButton.layer.cornerRadius = 6 + pillButton.layer.cornerRadius = 7 pillButton.layer.borderWidth = 1 - pillButton.layer.borderColor = accentColor.withAlphaComponent(0.2).cgColor + pillButton.layer.borderColor = borderColor.cgColor pillButton.clipsToBounds = true - pillButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) + pillButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 9, bottom: 4, right: 9) pillButton.addTarget(self, action: #selector(pillTapped), for: .touchUpInside) pillButton.translatesAutoresizingMaskIntoConstraints = false addSubview(pillButton) @@ -102,17 +81,10 @@ final class TmuxBarView: UIView { NSLayoutConstraint.activate([ pillButton.centerYAnchor.constraint(equalTo: centerYAnchor), pillButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - pillButton.heightAnchor.constraint(equalToConstant: 26), - - border.leadingAnchor.constraint(equalTo: leadingAnchor), - border.trailingAnchor.constraint(equalTo: trailingAnchor), - border.bottomAnchor.constraint(equalTo: bottomAnchor), - border.heightAnchor.constraint(equalToConstant: 1), + pillButton.heightAnchor.constraint(equalToConstant: 28), ]) } - // MARK: - Update - func update(session: TmuxSession?, windows: [TmuxWindow]) { self.currentSession = session self.windows = windows @@ -129,38 +101,35 @@ final class TmuxBarView: UIView { let activeWindow = windows.first(where: { $0.isActive }) let text = NSMutableAttributedString() - // Session icon text.append(NSAttributedString( string: "⬡ ", attributes: [ - .font: UIFont.systemFont(ofSize: 10), + .font: UIFont.systemFont(ofSize: 9, weight: .bold), .foregroundColor: accentColor, ] )) - // Session name text.append(NSAttributedString( string: session.name, attributes: [ - .font: UIFont.systemFont(ofSize: 11, weight: .semibold), + .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .semibold), .foregroundColor: accentColor, ] )) - // Separator + active window name - if let win = activeWindow { + if let window = activeWindow { text.append(NSAttributedString( - string: " › ", + string: " › ", attributes: [ - .font: UIFont.systemFont(ofSize: 10), - .foregroundColor: accentColor.withAlphaComponent(0.4), + .font: UIFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: accentColor.withAlphaComponent(0.5), ] )) text.append(NSAttributedString( - string: win.name, + string: window.name, attributes: [ - .font: UIFont.systemFont(ofSize: 11, weight: .medium), - .foregroundColor: accentColor.withAlphaComponent(0.7), + .font: UIFont.systemFont(ofSize: 10, weight: .medium), + .foregroundColor: UIColor.white.withAlphaComponent(0.92), ] )) } @@ -168,14 +137,10 @@ final class TmuxBarView: UIView { pillButton.setAttributedTitle(text, for: .normal) } - // MARK: - Actions - @objc private func pillTapped() { delegate?.tmuxBarDidTap() } - // MARK: - Layout - override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: 34) } diff --git a/MoriRemote/MoriRemote/MoriRemoteApp.swift b/MoriRemote/MoriRemote/MoriRemoteApp.swift index 48b4080e..370a307c 100644 --- a/MoriRemote/MoriRemote/MoriRemoteApp.swift +++ b/MoriRemote/MoriRemote/MoriRemoteApp.swift @@ -4,39 +4,101 @@ import SwiftUI struct MoriRemoteApp: App { @State private var coordinator = ShellCoordinator() @State private var store = ServerStore() + @State private var regularWidthSelection = RegularWidthServerSelection() + @State private var terminalSessionHost = TerminalSessionHost() var body: some Scene { WindowGroup { - RootView() - .environment(coordinator) - .environment(store) + RootView( + regularWidthSelection: regularWidthSelection, + terminalSessionHost: terminalSessionHost + ) + .environment(coordinator) + .environment(store) } } } private struct RootView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(ShellCoordinator.self) private var coordinator + let regularWidthSelection: RegularWidthServerSelection + let terminalSessionHost: TerminalSessionHost + var body: some View { Group { - switch coordinator.state { - case .disconnected, .connecting: - ServerListView() - - case .connected, .shell: - if let server = coordinator.activeServer { - TerminalScreen( - serverName: server.displayName, - onDisconnect: { - Task { await coordinator.disconnect() } - } - ) - } else { - ServerListView() - } + if horizontalSizeClass == .regular { + regularWidthContent + } else { + compactContent } } .animation(.easeInOut(duration: 0.25), value: coordinator.state) + .onAppear { + terminalSessionHost.handleCoordinatorStateChange( + coordinator.state, + activeServerID: coordinator.activeServer?.id + ) + } + .onChange(of: coordinator.state) { _, newState in + terminalSessionHost.handleCoordinatorStateChange( + newState, + activeServerID: coordinator.activeServer?.id + ) + } + .onChange(of: coordinator.activeServer?.id) { _, newServerID in + regularWidthSelection.remember(coordinator.activeServer) + terminalSessionHost.handleCoordinatorStateChange( + coordinator.state, + activeServerID: newServerID + ) + } + } + + @ViewBuilder + private var compactContent: some View { + switch coordinator.state { + case .disconnected, .connecting: + ServerListView() + + case .connected, .shell: + terminalContent + } + } + + @ViewBuilder + private var regularWidthContent: some View { + switch coordinator.state { + case .disconnected, .connecting: + RegularWidthServerBrowserView(selection: regularWidthSelection) + + case .connected, .shell: + terminalContent + } + } + + @ViewBuilder + private var terminalContent: some View { + if let server = coordinator.activeServer { + TerminalScreen( + sessionHost: terminalSessionHost, + serverName: server.displayName, + onDisconnect: returnToDisconnectedBrowser, + onSwitchHost: returnToDisconnectedBrowser + ) + } else if horizontalSizeClass == .regular { + RegularWidthServerBrowserView(selection: regularWidthSelection) + } else { + ServerListView() + } + } + + private func returnToDisconnectedBrowser() { + let activeServer = coordinator.activeServer + regularWidthSelection.remember(activeServer) + regularWidthSelection.select(activeServer) + Task { await coordinator.disconnect() } } } diff --git a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings index 5e20dd60..97c39195 100644 --- a/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/en.lproj/Localizable.strings @@ -1,19 +1,123 @@ +"22" = "22"; +"A connection is already in progress. Finish or cancel it before starting another one." = "A connection is already in progress. Finish or cancel it before starting another one."; +"Add a server to get started." = "Add a server to get started."; +"Add a server to start browsing your remote workspaces on iPad." = "Add a server to start browsing your remote workspaces on iPad."; +"Add Server" = "Add Server"; "Attach Session" = "Attach Session"; "Attaching session..." = "Attaching session..."; +"AUTHENTICATION" = "AUTHENTICATION"; +"Cancel" = "Cancel"; +"Choose a server from the sidebar to review its connection details before connecting." = "Choose a server from the sidebar to review its connection details before connecting."; "Connect" = "Connect"; +"Connected" = "Connected"; "Connecting..." = "Connecting..."; +"Connecting…" = "Connecting…"; +"Connecting to Server" = "Connecting to Server"; +"Connection" = "Connection"; +"CONNECTION" = "CONNECTION"; +"Connection Failed" = "Connection Failed"; +"Connection busy" = "Connection busy"; +"Close Window" = "Close Window"; +"Default Session" = "Default Session"; +"Delete" = "Delete"; +"Delete %@?" = "Delete %@?"; +"Disconnect" = "Disconnect"; +"Edit" = "Edit"; +"Edit Server" = "Edit Server"; "Enter text" = "Enter text"; "Host" = "Host"; +"hostname or IP" = "hostname or IP"; +"LABEL" = "LABEL"; +"main" = "main"; "Mori Remote" = "Mori Remote"; +"MoriRemote is opening the SSH connection. You can keep browsing servers while this attempt finishes." = "MoriRemote is opening the SSH connection. You can keep browsing servers while this attempt finishes."; +"Checking credentials and opening the SSH session. You can keep browsing servers while this attempt finishes." = "Checking credentials and opening the SSH session. You can keep browsing servers while this attempt finishes."; +"New Window After" = "New Window After"; +"My Server" = "My Server"; +"New Session" = "New Session"; +"No Servers" = "No Servers"; +"No tmux sessions" = "No tmux sessions"; +"Not Connected" = "Not Connected"; +"Offline" = "Offline"; +"Opening shell…" = "Opening shell…"; +"password" = "password"; "Password" = "Password"; +"Preparing Terminal" = "Preparing Terminal"; "Port" = "Port"; "Port must be a valid positive number." = "Port must be a valid positive number."; +"Ready to connect" = "Ready to connect"; +"Rename" = "Rename"; +"Rename Session" = "Rename Session"; +"Retry" = "Retry"; +"Review the server settings, then connect when you're ready." = "Review the server settings, then connect when you're ready."; +"Switch to Session" = "Switch to Session"; +"Switch to Window" = "Switch to Window"; +"Save Changes" = "Save Changes"; +"Select a Server" = "Select a Server"; "Send" = "Send"; +"Servers" = "Servers"; "Session" = "Session"; +"SSH Connected" = "SSH Connected"; "Session Name" = "Session Name"; +"Session name" = "Session name"; "Session name cannot be empty." = "Session name cannot be empty."; "SSH connection is not available." = "SSH connection is not available."; +"SSH couldn’t connect with the current settings." = "SSH couldn’t connect with the current settings."; +"Start tmux to manage\nwindows from here." = "Start tmux to manage\nwindows from here."; +"Status" = "Status"; +"Switch Host" = "Switch Host"; +"Opening the interactive shell and checking tmux windows." = "Opening the interactive shell and checking tmux windows."; "Terminal renderer is not ready yet." = "Terminal renderer is not ready yet."; -"Username" = "Username"; +"Window" = "Window"; +"TMUX SESSION" = "TMUX SESSION"; "tmux did not report a pane ID." = "tmux did not report a pane ID."; +"Kill Session" = "Kill Session"; "tmux session exited." = "tmux session exited."; +"username" = "username"; +"Username" = "Username"; +"Active Keys" = "Active Keys"; +"Alt/Meta modifier" = "Alt/Meta modifier"; +"Arrow down (auto-repeat)" = "Arrow down (auto-repeat)"; +"Arrow left (auto-repeat)" = "Arrow left (auto-repeat)"; +"Arrow right (auto-repeat)" = "Arrow right (auto-repeat)"; +"Arrow up (auto-repeat)" = "Arrow up (auto-repeat)"; +"Close Pane" = "Close Pane"; +"Close pane (⌘W)" = "Close pane (⌘W)"; +"Control modifier (sticky)" = "Control modifier (sticky)"; +"Customize Keys" = "Customize Keys"; +"Detach" = "Detach"; +"Detach session" = "Detach session"; +"Done" = "Done"; +"Escape key" = "Escape key"; +"Function Keys" = "Function Keys"; +"Home key" = "Home key"; +"Modifiers" = "Modifiers"; +"Navigation" = "Navigation"; +"Next Pane" = "Next Pane"; +"Next pane (⌘])" = "Next pane (⌘])"; +"Next Tab" = "Next Tab"; +"New Tab" = "New Tab"; +"New tab (⌘T)" = "New tab (⌘T)"; +"No keys added" = "No keys added"; +"Page down" = "Page down"; +"Page up" = "Page up"; +"Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close." = "Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close."; +"Previous Pane" = "Previous Pane"; +"Previous pane (⌘[)" = "Previous pane (⌘[)"; +"Previous Tab" = "Previous Tab"; +"Reset" = "Reset"; +"Send key" = "Send key"; +"Split down (⌘⇧D)" = "Split down (⌘⇧D)"; +"Split Down" = "Split Down"; +"Split right (⌘D)" = "Split right (⌘D)"; +"Split Right" = "Split Right"; +"Symbols" = "Symbols"; +"Tab key" = "Tab key"; +"tmux" = "tmux"; +"Tmux prefix (Ctrl+B)" = "Tmux prefix (Ctrl+B)"; +"Tmux Shortcuts" = "Tmux Shortcuts"; +"Toggle Zoom" = "Toggle Zoom"; +"Toggle zoom (⌘⇧↩)" = "Toggle zoom (⌘⇧↩)"; +"End key" = "End key"; +"Next tab (⌘⇧])" = "Next tab (⌘⇧])"; +"Previous tab (⌘⇧[)" = "Previous tab (⌘⇧[)"; diff --git a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings index ed86436c..4156435f 100644 --- a/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings +++ b/MoriRemote/MoriRemote/Resources/zh-Hans.lproj/Localizable.strings @@ -1,19 +1,123 @@ +"22" = "22"; +"A connection is already in progress. Finish or cancel it before starting another one." = "已有连接正在进行中。请先等待完成或取消后再发起新的连接。"; +"Add a server to get started." = "添加一个服务器即可开始使用。"; +"Add a server to start browsing your remote workspaces on iPad." = "添加一个服务器,即可在 iPad 上浏览你的远程工作区。"; +"Add Server" = "添加服务器"; "Attach Session" = "连接会话"; "Attaching session..." = "正在连接会话..."; +"AUTHENTICATION" = "身份验证"; +"Cancel" = "取消"; +"Choose a server from the sidebar to review its connection details before connecting." = "从侧边栏选择一个服务器,先查看连接详情,再决定是否连接。"; "Connect" = "连接"; +"Connected" = "已连接"; "Connecting..." = "连接中..."; +"Connecting…" = "正在连接…"; +"Connecting to Server" = "正在连接服务器"; +"Connection" = "连接"; +"CONNECTION" = "连接信息"; +"Connection Failed" = "连接失败"; +"Connection busy" = "连接繁忙"; +"Close Window" = "关闭窗口"; +"Default Session" = "默认会话"; +"Delete" = "删除"; +"Delete %@?" = "删除 %@?"; +"Disconnect" = "断开连接"; +"Edit" = "编辑"; +"Edit Server" = "编辑服务器"; "Enter text" = "输入文本"; "Host" = "主机"; +"hostname or IP" = "主机名或 IP"; +"LABEL" = "标签"; +"main" = "main"; "Mori Remote" = "Mori 远程"; +"MoriRemote is opening the SSH connection. You can keep browsing servers while this attempt finishes." = "MoriRemote 正在建立 SSH 连接。等待本次尝试完成期间,你仍然可以继续浏览其他服务器。"; +"Checking credentials and opening the SSH session. You can keep browsing servers while this attempt finishes." = "正在检查凭据并建立 SSH 会话。等待本次尝试完成期间,你仍然可以继续浏览其他服务器。"; +"New Window After" = "在后方新建窗口"; +"My Server" = "我的服务器"; +"New Session" = "新建会话"; +"No Servers" = "没有服务器"; +"No tmux sessions" = "没有 tmux 会话"; +"Not Connected" = "未连接"; +"Offline" = "离线"; +"Opening shell…" = "正在打开 shell…"; +"password" = "密码"; "Password" = "密码"; +"Preparing Terminal" = "正在准备终端"; "Port" = "端口"; "Port must be a valid positive number." = "端口必须是有效的正整数。"; +"Ready to connect" = "可以连接"; +"Rename" = "重命名"; +"Rename Session" = "重命名会话"; +"Retry" = "重试"; +"Review the server settings, then connect when you're ready." = "先检查服务器设置,准备好后再发起连接。"; +"Switch to Session" = "切换到会话"; +"Switch to Window" = "切换到窗口"; +"Save Changes" = "保存更改"; +"Select a Server" = "选择一个服务器"; "Send" = "发送"; +"Servers" = "服务器"; "Session" = "会话"; +"SSH Connected" = "SSH 已连接"; "Session Name" = "会话名称"; +"Session name" = "会话名称"; "Session name cannot be empty." = "会话名称不能为空。"; "SSH connection is not available." = "SSH 连接不可用。"; +"SSH couldn’t connect with the current settings." = "SSH 无法使用当前设置建立连接。"; +"Start tmux to manage\nwindows from here." = "启动 tmux 后,即可在这里管理\n窗口。"; +"Status" = "状态"; +"Switch Host" = "切换主机"; +"Opening the interactive shell and checking tmux windows." = "正在打开交互式 shell,并检查 tmux 窗口。"; "Terminal renderer is not ready yet." = "终端渲染器尚未就绪。"; -"Username" = "用户名"; +"Window" = "窗口"; +"TMUX SESSION" = "TMUX 会话"; "tmux did not report a pane ID." = "tmux 没有返回 pane ID。"; +"Kill Session" = "结束会话"; "tmux session exited." = "tmux 会话已退出。"; +"username" = "用户名"; +"Username" = "用户名"; +"Active Keys" = "已启用按键"; +"Alt/Meta modifier" = "Alt/Meta 修饰键"; +"Arrow down (auto-repeat)" = "向下方向键(支持连发)"; +"Arrow left (auto-repeat)" = "向左方向键(支持连发)"; +"Arrow right (auto-repeat)" = "向右方向键(支持连发)"; +"Arrow up (auto-repeat)" = "向上方向键(支持连发)"; +"Close Pane" = "关闭面板"; +"Close pane (⌘W)" = "关闭面板(⌘W)"; +"Control modifier (sticky)" = "Control 修饰键(可保持)"; +"Customize Keys" = "自定义按键"; +"Detach" = "分离"; +"Detach session" = "分离会话"; +"Done" = "完成"; +"Escape key" = "Escape 键"; +"Function Keys" = "功能键"; +"Home key" = "Home 键"; +"Modifiers" = "修饰键"; +"Navigation" = "导航"; +"Next Pane" = "下一个面板"; +"Next pane (⌘])" = "下一个面板(⌘])"; +"Next Tab" = "下一个标签"; +"New Tab" = "新建标签"; +"New tab (⌘T)" = "新建标签(⌘T)"; +"No keys added" = "尚未添加按键"; +"Page down" = "向下翻页"; +"Page up" = "向上翻页"; +"Pick the keys you want in the terminal accessory bar. Reorder active keys to keep your most-used actions close." = "选择要显示在终端辅助栏中的按键。你也可以调整顺序,把最常用的操作放在手边。"; +"Previous Pane" = "上一个面板"; +"Previous pane (⌘[)" = "上一个面板(⌘[)"; +"Previous Tab" = "上一个标签"; +"Reset" = "重置"; +"Send key" = "发送按键"; +"Split down (⌘⇧D)" = "向下分屏(⌘⇧D)"; +"Split Down" = "向下分屏"; +"Split right (⌘D)" = "向右分屏(⌘D)"; +"Split Right" = "向右分屏"; +"Symbols" = "符号"; +"Tab key" = "Tab 键"; +"tmux" = "tmux"; +"Tmux prefix (Ctrl+B)" = "Tmux 前缀(Ctrl+B)"; +"Tmux Shortcuts" = "Tmux 快捷操作"; +"Toggle Zoom" = "切换缩放"; +"Toggle zoom (⌘⇧↩)" = "切换缩放(⌘⇧↩)"; +"End key" = "End 键"; +"Next tab (⌘⇧])" = "下一个标签(⌘⇧])"; +"Previous tab (⌘⇧[)" = "上一个标签(⌘⇧[)"; diff --git a/MoriRemote/MoriRemote/ShellCoordinator.swift b/MoriRemote/MoriRemote/ShellCoordinator.swift index 4ebc6797..5629c251 100644 --- a/MoriRemote/MoriRemote/ShellCoordinator.swift +++ b/MoriRemote/MoriRemote/ShellCoordinator.swift @@ -27,12 +27,18 @@ enum ShellState: Equatable, Sendable { enum ShellError: LocalizedError { case notConnected + case missingCredentials + case connectionTimedOut case shellFailed(String) var errorDescription: String? { switch self { case .notConnected: return String.localized("SSH connection is not available.") + case .missingCredentials: + return String.localized("Saved server credentials are incomplete. Edit the server and enter the password again.") + case .connectionTimedOut: + return String.localized("SSH connection timed out. Check the host, password, and network, then try again.") case .shellFailed(let reason): return String.localized("Shell failed: \(reason)") } @@ -46,6 +52,8 @@ final class ShellCoordinator { var lastError: Error? var activeServer: Server? + private var connectionGeneration: UInt64 = 0 + // Tmux state (observable for sidebar) var tmuxSessions: [TmuxSession] = [] var tmuxActiveSession: TmuxSession? @@ -66,38 +74,61 @@ final class ShellCoordinator { // MARK: - Connect / Disconnect func connect(server: Server) async { + let generation = beginConnectionGeneration() await resetConnection() + + guard isCurrentConnection(generation) else { return } + activeServer = server state = .connecting lastError = nil + guard !server.password.isEmpty else { + lastError = ShellError.missingCredentials + state = .disconnected + return + } + let manager = SSHConnectionManager() do { - try await manager.connect( - host: server.host.trimmingCharacters(in: .whitespacesAndNewlines), - port: server.port, - user: server.username.trimmingCharacters(in: .whitespacesAndNewlines), - auth: .password(server.password) - ) + try await withTimeout(seconds: 15) { + try await manager.connect( + host: server.host.trimmingCharacters(in: .whitespacesAndNewlines), + port: server.port, + user: server.username.trimmingCharacters(in: .whitespacesAndNewlines), + auth: .password(server.password) + ) + } + + guard isCurrentConnection(generation) else { + await manager.disconnect() + return + } + sshManager = manager state = .connected } catch { await manager.disconnect() + guard isCurrentConnection(generation) else { return } lastError = error state = .disconnected } } func disconnect() async { + let generation = beginConnectionGeneration() await resetConnection() + guard isCurrentConnection(generation) else { return } state = .disconnected } // MARK: - Shell func openShell(renderer: SwiftTermRenderer) async { + let generation = connectionGeneration + guard case .connected = state else { - if case .shell = state { + if case .shell = state, isCurrentConnection(generation) { wireRenderer(renderer) renderer.activateKeyboard() } @@ -117,25 +148,36 @@ final class ShellCoordinator { do { let channel = try await sshManager.openShellChannel(cols: cols, rows: rows) + + guard isCurrentConnection(generation), activeServer != nil else { + await channel.close() + return + } + shellChannel = channel outputTask = Task { [weak self] in do { for try await chunk in channel.inbound { guard let self else { return } - self.renderer?.feedBytes(chunk) + guard await self.isCurrentConnection(generation) else { return } + await MainActor.run { + self.renderer?.feedBytes(chunk) + } } } catch { log.error("Shell inbound error: \(error)") } guard let self, !Task.isCancelled else { return } - await self.handleShellClosed() + guard await self.isCurrentConnection(generation) else { return } + await self.handleShellClosed(generation: generation) } state = .shell renderer.activateKeyboard() - startTmuxPolling() + startTmuxPolling(generation: generation) } catch { + guard isCurrentConnection(generation) else { return } await resetConnection() lastError = error state = .disconnected @@ -148,6 +190,10 @@ final class ShellCoordinator { var accessoryBar: TerminalAccessoryBar? private func wireRenderer(_ renderer: SwiftTermRenderer) { + if let previousRenderer = self.renderer, previousRenderer !== renderer { + detachAccessoryBar(from: previousRenderer) + } + self.renderer = renderer renderer.inputHandler = { [weak self] data in self?.sendInput(data) @@ -156,19 +202,47 @@ final class ShellCoordinator { self?.sendResize(Int(newCols), Int(newRows)) } - // Wire the custom accessory bar — must set before activateKeyboard + // Wire the custom accessory bar — must set before activateKeyboard. + // Reusing the same accessory view across terminal responders is fine, + // but UIKit stays happier if we explicitly detach it from the previous + // responder before reassigning it during host switches and reconnects. if let bar = accessoryBar { let tv = renderer.swiftTermView bar.terminalView = tv - tv.inputAccessoryView = bar - // Force the keyboard to pick up the new accessory view - tv.reloadInputViews() + if tv.inputAccessoryView !== bar { + tv.inputAccessoryView = bar + } + if tv.window != nil || tv.isFirstResponder { + tv.reloadInputViews() + } bar.onTmuxCommand = { [weak self] cmd in self?.handleTmuxCommand(cmd) } } } + private func detachAccessoryBar(from renderer: SwiftTermRenderer?) { + guard let accessoryBar else { return } + + if let renderer { + let terminalView = renderer.swiftTermView + if terminalView.inputAccessoryView != nil { + terminalView.inputAccessoryView = nil + if terminalView.window != nil || terminalView.isFirstResponder { + terminalView.reloadInputViews() + } + } + + if accessoryBar.terminalView === terminalView { + accessoryBar.terminalView = nil + } + } else { + accessoryBar.terminalView = nil + } + + accessoryBar.onTmuxCommand = nil + } + private func sendInput(_ data: Data) { guard let shellChannel else { return } Task { @@ -192,6 +266,13 @@ final class ShellCoordinator { private var tmuxPollTask: Task? func handleTmuxCommand(_ command: TmuxCommand) { + guard state == .shell, shellChannel != nil else { + log.debug("Ignoring tmux command while shell is inactive") + return + } + + let generation = connectionGeneration + // Send tmux CLI commands through the shell channel (not exec). // The shell is running INSIDE tmux, so $TMUX is set and commands // correctly target the current session/window/pane. @@ -201,29 +282,34 @@ final class ShellCoordinator { let sequence = "\u{15}\(cmd)\n" sendInput(Data(sequence.utf8)) - // Refresh tmux state after a short delay - Task { + // Refresh tmux state after a short delay. + Task { [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) - await self.pollTmuxState() + guard let self, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) } } - func startTmuxPolling() { + func startTmuxPolling(generation: UInt64) { tmuxPollTask?.cancel() - tmuxPollTask = Task { + tmuxPollTask = Task { [weak self] in + guard let self else { return } + guard await self.isCurrentConnection(generation) else { return } + // Detect tmux path first - await self.detectTmuxPath() + await self.detectTmuxPath(generation: generation) + guard await self.isCurrentConnection(generation) else { return } // Initial poll after shell starts try? await Task.sleep(nanoseconds: 1_500_000_000) - guard !Task.isCancelled else { return } - await self.pollTmuxState() + guard !Task.isCancelled, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) // Poll every 5 seconds using NoPTY exec channels while !Task.isCancelled { try? await Task.sleep(nanoseconds: 5_000_000_000) - guard !Task.isCancelled else { return } - await self.pollTmuxState() + guard !Task.isCancelled, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) } } } @@ -231,8 +317,8 @@ final class ShellCoordinator { /// Resolved full path to tmux binary (exec channels don't have user's PATH). private var tmuxPath: String = "tmux" - private func detectTmuxPath() async { - guard let sshManager, await sshManager.isConnected else { return } + private func detectTmuxPath(generation: UInt64) async { + guard isCurrentConnection(generation), let sshManager, await sshManager.isConnected else { return } // Try common paths — exec channels don't source .zshrc/.bashrc let candidates = [ "/opt/homebrew/bin/tmux", @@ -270,7 +356,11 @@ final class ShellCoordinator { log.info("Could not detect tmux path, using default: tmux") } - private func pollTmuxState() async { + private func pollTmuxState(generation: UInt64? = nil) async { + if let generation, !isCurrentConnection(generation) { + return + } + guard let sshManager, await sshManager.isConnected else { log.debug("tmux poll: no SSH manager or disconnected") return @@ -290,6 +380,10 @@ final class ShellCoordinator { "\(tmuxPath) list-sessions -F '#{session_name}:#{session_windows}:#{session_attached}' 2>/dev/null" ) } + if let generation, !isCurrentConnection(generation) { + return + } + log.info("tmux sessions raw: '\(sessionsRaw)'") guard !sessionsRaw.isEmpty else { accessoryBar?.updateTmux(session: nil, windows: []) @@ -336,6 +430,10 @@ final class ShellCoordinator { ) } + if let generation, !isCurrentConnection(generation) { + return + } + let windows = windowsRaw.split(separator: "\n").compactMap { line -> TmuxWindow? in let parts = line.split(separator: ":", maxSplits: 3) guard parts.count >= 3 else { return nil } @@ -357,6 +455,10 @@ final class ShellCoordinator { } } + if let generation, !isCurrentConnection(generation) { + return + } + accessoryBar?.updateTmux(session: activeSession, windows: activeWindows) self.tmuxSessions = enrichedSessions self.tmuxActiveSession = activeSession @@ -421,12 +523,22 @@ final class ShellCoordinator { /// Force a tmux state refresh. func refreshTmuxState() { - Task { await pollTmuxState() } + let generation = connectionGeneration + Task { [weak self] in + guard let self, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) + } } /// Attach to a tmux session via the shell channel. /// Used when not currently inside tmux (switch-client won't work). private func attachTmuxSession(_ sessionName: String, selectWindow: Int? = nil) { + guard state == .shell, shellChannel != nil else { + log.debug("Ignoring tmux attach while shell is inactive") + return + } + + let generation = connectionGeneration var cmd = "tmux attach-session -t '\(sessionName)'" if let win = selectWindow { // Select the target window first, then attach @@ -435,43 +547,60 @@ final class ShellCoordinator { log.info("Tmux attach via shell: \(cmd)") let sequence = "\u{15}\(cmd)\n" sendInput(Data(sequence.utf8)) - Task { + Task { [weak self] in try? await Task.sleep(nanoseconds: 1_000_000_000) - await self.pollTmuxState() + guard let self, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) } } /// Run a tmux command via NoPTY exec channel and refresh state. private func runTmuxCommand(_ cmd: String) { + guard state == .shell else { + log.debug("Ignoring tmux exec while shell is inactive") + return + } guard let sshManager else { log.error("runTmuxCommand: no SSH manager") return } + + let generation = connectionGeneration log.info("Tmux exec: \(cmd)") - Task { + Task { [weak self] in do { _ = try await sshManager.runCommandNoPTY(cmd) } catch { log.error("Tmux exec failed: \(error)") } try? await Task.sleep(nanoseconds: 300_000_000) - await self.pollTmuxState() + guard let self, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) } } /// Send a tmux command through the shell channel and refresh state. private func sendTmuxShellCommand(_ cmd: String) { + guard state == .shell, shellChannel != nil else { + log.debug("Ignoring tmux shell command while shell is inactive") + return + } + + let generation = connectionGeneration log.info("Tmux shell command: \(cmd)") let sequence = "\u{15}\(cmd)\n" sendInput(Data(sequence.utf8)) - Task { + Task { [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) - await self.pollTmuxState() + guard let self, await self.isCurrentConnection(generation) else { return } + await self.pollTmuxState(generation: generation) } } - private func handleShellClosed() async { + private func handleShellClosed(generation: UInt64) async { + guard isCurrentConnection(generation) else { return } await resetConnection() + guard isCurrentConnection(generation) else { return } lastError = ShellError.shellFailed("Shell session ended.") state = .disconnected } @@ -484,6 +613,8 @@ final class ShellCoordinator { renderer?.inputHandler = nil renderer?.sizeChangeHandler = nil + detachAccessoryBar(from: renderer) + renderer?.deactivateKeyboard() renderer = nil if let shellChannel { @@ -500,5 +631,31 @@ final class ShellCoordinator { tmuxSessions = [] tmuxActiveSession = nil tmuxWindows = [] + tmuxPath = "tmux" + } + + private func beginConnectionGeneration() -> UInt64 { + connectionGeneration &+= 1 + return connectionGeneration + } + + private func isCurrentConnection(_ generation: UInt64) -> Bool { + generation == connectionGeneration + } + + private func withTimeout(seconds: Double, operation: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw ShellError.connectionTimedOut + } + + let result = try await group.next()! + group.cancelAll() + return result + } } } diff --git a/MoriRemote/MoriRemote/Theme.swift b/MoriRemote/MoriRemote/Theme.swift index 2cf3268d..64ae2a69 100644 --- a/MoriRemote/MoriRemote/Theme.swift +++ b/MoriRemote/MoriRemote/Theme.swift @@ -1,30 +1,61 @@ import SwiftUI -/// Design tokens for the app's dark terminal theme. +/// MoriRemote UI tokens aligned with the Mac app's quieter, denser workspace language. enum Theme { // MARK: - Colors - static let bg = Color(red: 0.07, green: 0.07, blue: 0.09) - static let cardBg = Color(red: 0.11, green: 0.11, blue: 0.14) - static let cardBorder = Color.white.opacity(0.06) - static let accent = Color(red: 0.30, green: 0.85, blue: 0.75) // teal / cyan - static let destructive = Color(red: 0.95, green: 0.35, blue: 0.35) - static let textPrimary = Color.white - static let textSecondary = Color.white.opacity(0.55) - static let textTertiary = Color.white.opacity(0.35) + static let bg = Color(red: 0.07, green: 0.08, blue: 0.10) + static let sidebarBg = Color(red: 0.08, green: 0.09, blue: 0.11) + static let terminalBg = Color.black + static let cardBg = Color.white.opacity(0.045) + static let elevatedBg = Color.white.opacity(0.07) + static let mutedSurface = Color.white.opacity(0.04) + static let rowHover = Color.white.opacity(0.035) + static let cardBorder = Color.white.opacity(0.08) + static let divider = Color.white.opacity(0.08) + + static let accent = Color.accentColor + static let accentSoft = Theme.accent.opacity(0.12) + static let accentBorder = Theme.accent.opacity(0.28) + + static let success = Color.green.opacity(0.95) + static let warning = Color.yellow.opacity(0.95) + static let destructive = Color.red.opacity(0.9) + + static let textPrimary = Color.white.opacity(0.96) + static let textSecondary = Color.white.opacity(0.64) + static let textTertiary = Color.white.opacity(0.38) + + // MARK: - Spacing + + static let rowSpacing: CGFloat = 8 + static let sectionSpacing: CGFloat = 10 + static let contentInset: CGFloat = 16 // MARK: - Shapes - static let cardRadius: CGFloat = 14 - static let buttonRadius: CGFloat = 12 - static let sheetRadius: CGFloat = 24 + static let cardRadius: CGFloat = 10 + static let rowRadius: CGFloat = 7 + static let buttonRadius: CGFloat = 10 + static let sheetRadius: CGFloat = 20 + + // MARK: - Typography + + static let sectionHeaderFont = Font.system(size: 11, weight: .bold) + static let rowTitleFont = Font.system(size: 13.5, weight: .semibold) + static let rowSubtitleFont = Font.system(size: 11) + static let monoCaptionFont = Font.system(size: 10.5, design: .monospaced) + static let monoDetailFont = Font.system(size: 11, design: .monospaced) + static let shortcutFont = Font.system(size: 10, design: .monospaced) // MARK: - View Modifiers - struct CardStyle: ViewModifier { + struct PanelStyle: ViewModifier { + var padding: CGFloat = 16 + func body(content: Content) -> some View { content - .padding(16) + .padding(padding) .background(Theme.cardBg, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) .overlay( RoundedRectangle(cornerRadius: Theme.cardRadius) @@ -33,6 +64,22 @@ enum Theme { } } + struct RowSurfaceStyle: ViewModifier { + let isSelected: Bool + + func body(content: Content) -> some View { + content + .background( + isSelected ? Theme.accentSoft : Theme.mutedSurface, + in: RoundedRectangle(cornerRadius: Theme.rowRadius) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.rowRadius) + .strokeBorder(isSelected ? Theme.accentBorder : Theme.cardBorder, lineWidth: 1) + ) + } + } + struct PrimaryButtonStyle: ButtonStyle { let disabled: Bool @@ -42,36 +89,85 @@ enum Theme { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.body.weight(.semibold)) - .foregroundStyle(disabled ? Theme.textTertiary : Theme.bg) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(disabled ? Theme.textTertiary : Theme.textPrimary) .frame(maxWidth: .infinity) - .frame(height: 50) + .frame(height: 44) .background( - disabled ? Theme.accent.opacity(0.3) : Theme.accent, + disabled ? Theme.accent.opacity(0.24) : Theme.accent, in: RoundedRectangle(cornerRadius: Theme.buttonRadius) ) - .scaleEffect(configuration.isPressed ? 0.97 : 1) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + .opacity(configuration.isPressed ? 0.92 : 1) + .scaleEffect(configuration.isPressed ? 0.985 : 1) + .animation(.easeOut(duration: 0.14), value: configuration.isPressed) + } + } + + struct SecondaryButtonStyle: ButtonStyle { + let foreground: Color + let background: Color + let border: Color + + init( + foreground: Color = Theme.textPrimary, + background: Color = Theme.mutedSurface, + border: Color = Theme.cardBorder + ) { + self.foreground = foreground + self.background = background + self.border = border + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background(background, in: RoundedRectangle(cornerRadius: Theme.buttonRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.buttonRadius) + .strokeBorder(border, lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.88 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } } struct DarkFieldStyle: ViewModifier { func body(content: Content) -> some View { content + .font(.system(size: 14)) .padding(.horizontal, 14) .padding(.vertical, 12) - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.buttonRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.buttonRadius) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) .foregroundStyle(Theme.textPrimary) - } + } } } extension View { - func cardStyle() -> some View { - modifier(Theme.CardStyle()) + func cardStyle(padding: CGFloat = 16) -> some View { + modifier(Theme.PanelStyle(padding: padding)) + } + + func rowSurfaceStyle(selected: Bool = false) -> some View { + modifier(Theme.RowSurfaceStyle(isSelected: selected)) } func darkFieldStyle() -> some View { modifier(Theme.DarkFieldStyle()) } + + func moriSectionHeaderStyle() -> some View { + self + .font(Theme.sectionHeaderFont) + .tracking(1.2) + .textCase(.uppercase) + .foregroundStyle(Theme.textTertiary) + } } diff --git a/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift b/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift new file mode 100644 index 00000000..0b9a0a8e --- /dev/null +++ b/MoriRemote/MoriRemote/Views/RegularWidthServerBrowserView.swift @@ -0,0 +1,673 @@ +import Observation +import SwiftUI + +@MainActor +@Observable +final class RegularWidthServerSelection { + var selectedServerID: Server.ID? + private(set) var lastFocusedServerID: Server.ID? + + func selectedServer(in servers: [Server]) -> Server? { + guard let selectedServerID else { return nil } + return servers.first(where: { $0.id == selectedServerID }) + } + + func select(_ server: Server?) { + selectedServerID = server?.id + if let serverID = server?.id { + lastFocusedServerID = serverID + } + } + + func remember(_ server: Server?) { + guard let serverID = server?.id else { return } + lastFocusedServerID = serverID + if selectedServerID == nil { + selectedServerID = serverID + } + } + + func reconcile(with servers: [Server], preferredServer: Server? = nil) { + if let selectedServerID, + servers.contains(where: { $0.id == selectedServerID }) { + return + } + + if let preferredServer, + servers.contains(where: { $0.id == preferredServer.id }) { + selectedServerID = preferredServer.id + lastFocusedServerID = preferredServer.id + return + } + + if let lastFocusedServerID, + servers.contains(where: { $0.id == lastFocusedServerID }) { + selectedServerID = lastFocusedServerID + return + } + + selectedServerID = nil + } +} + +private enum RegularWidthServerDetailState { + case empty + case placeholder + case selected(Server) + case connecting(Server) + case failure(Server, String) +} + +struct RegularWidthServerBrowserView: View { + @Environment(ServerStore.self) private var store + @Environment(ShellCoordinator.self) private var coordinator + + let selection: RegularWidthServerSelection + + @State private var editingServer: Server? + @State private var showingAddSheet = false + @State private var columnVisibility = NavigationSplitViewVisibility.all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebar + } detail: { + detail + .background(Theme.bg.ignoresSafeArea()) + } + .navigationSplitViewStyle(.balanced) + .preferredColorScheme(.dark) + .sheet(isPresented: $showingAddSheet) { + ServerFormView(mode: .add) { server in + store.add(server) + selection.select(server) + } + } + .sheet(item: $editingServer) { server in + ServerFormView(mode: .edit(server)) { updated in + store.update(updated) + selection.select(updated) + clearFailureIfShowing(serverID: updated.id) + } + } + .onAppear { + selection.remember(coordinator.activeServer) + syncSelection(preferredServer: coordinator.activeServer) + } + .onChange(of: store.servers) { _, servers in + selection.reconcile(with: servers, preferredServer: coordinator.activeServer) + } + .onChange(of: coordinator.state) { _, state in + if state == .connecting || state == .shell || state == .connected { + selection.remember(coordinator.activeServer) + selection.select(coordinator.activeServer) + } + if state == .disconnected { + selection.remember(coordinator.activeServer) + syncSelection(preferredServer: coordinator.activeServer) + } + } + .onChange(of: coordinator.lastError != nil) { _, _ in + syncSelection(preferredServer: coordinator.activeServer) + } + } + + private var sidebar: some View { + ZStack { + Theme.sidebarBg.ignoresSafeArea() + + ServerListContentView( + servers: store.servers, + selectedServerID: selection.selectedServerID, + connectingServerID: connectingServerID, + onSelect: handleSidebarSelection, + onAdd: { showingAddSheet = true }, + onEdit: { editingServer = $0 }, + onDelete: handleDelete + ) + } + .navigationTitle(String(localized: "Servers")) + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 32, height: 32) + .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.rowRadius) + .strokeBorder(Theme.accentBorder, lineWidth: 1) + ) + } + } + } + } + + @ViewBuilder + private var detail: some View { + switch detailState { + case .empty: + ServerBrowserInfoState( + icon: "server.rack", + title: String(localized: "No Servers"), + message: String(localized: "Add a server to start browsing your remote workspaces on iPad."), + actionTitle: String(localized: "Add Server"), + actionSystemImage: "plus", + action: { showingAddSheet = true } + ) + + case .placeholder: + ServerBrowserInfoState( + icon: "sidebar.left", + title: String(localized: "Select a Server"), + message: String(localized: "Choose a server from the sidebar to review its connection details before connecting."), + actionTitle: nil, + actionSystemImage: nil, + action: nil + ) + + case .selected(let server): + ServerBrowserSelectedDetail( + server: server, + canConnect: coordinator.state == .disconnected, + onConnect: { connect(to: server) }, + onEdit: { editingServer = server } + ) + + case .connecting(let server): + ServerBrowserConnectingDetail(server: server) + + case .failure(let server, let message): + ServerBrowserFailureDetail( + server: server, + message: message, + onRetry: { connect(to: server) }, + onEdit: { editingServer = server } + ) + } + } + + private var connectingServerID: Server.ID? { + coordinator.state == .connecting ? coordinator.activeServer?.id : nil + } + + private var detailState: RegularWidthServerDetailState { + if store.servers.isEmpty { + return .empty + } + + if let connectingServer = connectingServer { + return .connecting(connectingServer) + } + + if let failure = failureContext { + return .failure(failure.server, failure.message) + } + + if let server = selection.selectedServer(in: store.servers) { + return .selected(server) + } + + return .placeholder + } + + private var connectingServer: Server? { + coordinator.state == .connecting ? coordinator.activeServer : nil + } + + private var failureContext: (server: Server, message: String)? { + guard coordinator.state == .disconnected, + let server = coordinator.activeServer, + let error = coordinator.lastError, + selection.selectedServerID == server.id else { + return nil + } + + return (server, error.localizedDescription) + } + + private func handleSidebarSelection(_ server: Server) { + selection.select(server) + + if coordinator.activeServer?.id != server.id { + coordinator.lastError = nil + } + } + + private func handleDelete(_ server: Server) { + if selection.selectedServerID == server.id { + selection.selectedServerID = nil + } + if coordinator.activeServer?.id == server.id { + coordinator.lastError = nil + } + store.delete(server) + syncSelection(preferredServer: coordinator.activeServer) + } + + private func connect(to server: Server) { + guard coordinator.state == .disconnected else { return } + selection.remember(server) + selection.select(server) + coordinator.lastError = nil + Task { await coordinator.connect(server: server) } + } + + private func clearFailureIfShowing(serverID: Server.ID) { + if coordinator.activeServer?.id == serverID { + coordinator.lastError = nil + } + } + + private func syncSelection(preferredServer: Server?) { + selection.reconcile(with: store.servers, preferredServer: preferredServer) + } +} + +private struct ServerBrowserInfoState: View { + let icon: String + let title: String + let message: String + let actionTitle: String? + let actionSystemImage: String? + let action: (() -> Void)? + + var body: some View { + ServerBrowserDetailLayout { + VStack(alignment: .leading, spacing: 14) { + Image(systemName: icon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.accent) + + Text(title) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(message) + .font(.system(size: 14)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + if let actionTitle, let actionSystemImage, let action { + Button(action: action) { + Label(actionTitle, systemImage: actionSystemImage) + } + .buttonStyle(Theme.PrimaryButtonStyle()) + .frame(maxWidth: 220) + .padding(.top, 4) + } + } + .cardStyle(padding: 24) + } + } +} + +private struct ServerBrowserSelectedDetail: View { + let server: Server + let canConnect: Bool + let onConnect: () -> Void + let onEdit: () -> Void + + var body: some View { + ServerBrowserDetailLayout { + VStack(alignment: .leading, spacing: 16) { + header + actionRow + connectionSection + sessionSection + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "Connection")) + .moriSectionHeaderStyle() + + HStack(alignment: .top, spacing: 14) { + RoundedRectangle(cornerRadius: Theme.cardRadius) + .fill(canConnect ? Theme.accentSoft : Theme.mutedSurface) + .frame(width: 42, height: 42) + .overlay { + Image(systemName: "terminal") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canConnect ? Theme.accent : Theme.textSecondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(server.displayName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(server.subtitle) + .font(Theme.monoDetailFont) + .foregroundStyle(Theme.textSecondary) + } + + Spacer(minLength: 12) + + ConnectionBadge( + title: canConnect ? String(localized: "Ready to connect") : String(localized: "Connection busy"), + color: canConnect ? Theme.accent : Theme.textTertiary + ) + } + } + .cardStyle(padding: 20) + } + + private var actionRow: some View { + HStack(spacing: 12) { + Button(action: onConnect) { + Label(String(localized: "Connect"), systemImage: "arrow.up.right.circle.fill") + } + .buttonStyle(Theme.PrimaryButtonStyle(disabled: !canConnect)) + .disabled(!canConnect) + + Button(action: onEdit) { + Label(String(localized: "Edit"), systemImage: "pencil") + } + .buttonStyle(Theme.SecondaryButtonStyle()) + } + } + + private var connectionSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "CONNECTION")) + .moriSectionHeaderStyle() + + VStack(spacing: 0) { + detailRow(label: String(localized: "Host"), value: server.host) + detailDivider + detailRow(label: String(localized: "Port"), value: String(server.port), useMonospace: true) + detailDivider + detailRow(label: String(localized: "Username"), value: server.username, useMonospace: true) + } + .cardStyle(padding: 0) + } + } + + private var sessionSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "TMUX SESSION")) + .moriSectionHeaderStyle() + + VStack(spacing: 0) { + detailRow(label: String(localized: "Default Session"), value: server.defaultSession, useMonospace: true) + detailDivider + detailNote(canConnect + ? String(localized: "Review the server settings, then connect when you're ready.") + : String(localized: "A connection is already in progress. Finish or cancel it before starting another one.")) + } + .cardStyle(padding: 0) + } + } + + private var detailDivider: some View { + Rectangle() + .fill(Theme.divider) + .frame(height: 1) + } + + private func detailRow(label: String, value: String, useMonospace: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(label) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + + Spacer(minLength: 12) + + Text(value) + .font(useMonospace ? Theme.monoDetailFont : .system(size: 14)) + .foregroundStyle(Theme.textPrimary) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private func detailNote(_ text: String) -> some View { + Text(text) + .font(.system(size: 13)) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + } +} + +private struct ServerBrowserConnectingDetail: View { + let server: Server + + var body: some View { + ServerBrowserDetailLayout { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "Connection")) + .moriSectionHeaderStyle() + + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + RoundedRectangle(cornerRadius: Theme.cardRadius) + .fill(Theme.accentSoft) + .frame(width: 42, height: 42) + .overlay { + ProgressView() + .tint(Theme.accent) + } + + VStack(alignment: .leading, spacing: 6) { + WorkflowStateBadge( + title: String(localized: "Connecting…"), + color: Theme.accent, + background: Theme.accentSoft, + border: Theme.accentBorder + ) + + Text(String(localized: "Connecting to Server")) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(server.displayName) + .font(Theme.rowTitleFont) + .foregroundStyle(Theme.textSecondary) + } + } + + WorkflowMetadataBlock(server: server) + + Text(String(localized: "Checking credentials and opening the SSH session. You can keep browsing servers while this attempt finishes.")) + .font(.system(size: 14)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .cardStyle(padding: 20) + } + } + } +} + +private struct ServerBrowserFailureDetail: View { + let server: Server + let message: String + let onRetry: () -> Void + let onEdit: () -> Void + + var body: some View { + ServerBrowserDetailLayout { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 14) { + RoundedRectangle(cornerRadius: Theme.cardRadius) + .fill(Theme.warning.opacity(0.12)) + .frame(width: 42, height: 42) + .overlay { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.warning) + } + + VStack(alignment: .leading, spacing: 6) { + WorkflowStateBadge( + title: String(localized: "Connection Failed"), + color: Theme.warning, + background: Theme.warning.opacity(0.12), + border: Theme.warning.opacity(0.24) + ) + + Text(server.displayName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(server.subtitle) + .font(Theme.monoDetailFont) + .foregroundStyle(Theme.textSecondary) + } + } + + WorkflowMetadataBlock(server: server) + } + .cardStyle(padding: 20) + + VStack(alignment: .leading, spacing: 10) { + Label(String(localized: "SSH couldn’t connect with the current settings."), systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.warning) + + Text(message) + .font(.system(size: 14)) + .foregroundStyle(Theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.cardRadius) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) + + HStack(spacing: 12) { + Button(action: onRetry) { + Label(String(localized: "Retry"), systemImage: "arrow.clockwise") + } + .buttonStyle(Theme.PrimaryButtonStyle()) + + Button(action: onEdit) { + Label(String(localized: "Edit Server"), systemImage: "slider.horizontal.3") + } + .buttonStyle(Theme.SecondaryButtonStyle()) + } + } + } + } +} + +private struct WorkflowMetadataBlock: View { + let server: Server + + var body: some View { + VStack(spacing: 0) { + metadataRow(label: String(localized: "Host"), value: server.host) + metadataDivider + metadataRow(label: String(localized: "Username"), value: server.username, monospace: true) + metadataDivider + metadataRow(label: String(localized: "Session"), value: server.defaultSession, monospace: true) + } + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.cardRadius) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) + } + + private var metadataDivider: some View { + Rectangle() + .fill(Theme.divider) + .frame(height: 1) + } + + private func metadataRow(label: String, value: String, monospace: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) + + Spacer(minLength: 10) + + Text(value) + .font(monospace ? Theme.monoDetailFont : .system(size: 13)) + .foregroundStyle(Theme.textPrimary) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + } +} + +private struct WorkflowStateBadge: View { + let title: String + let color: Color + let background: Color + let border: Color + + var body: some View { + Text(title) + .font(Theme.shortcutFont.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(background, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(border, lineWidth: 1) + ) + } +} + +private struct ServerBrowserDetailLayout: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + content + } + .frame(maxWidth: 640, alignment: .leading) + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.bg.ignoresSafeArea()) + } +} + +private struct ConnectionBadge: View { + let title: String + let color: Color + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + + Text(title) + .font(Theme.shortcutFont) + .foregroundStyle(color) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) + } +} diff --git a/MoriRemote/MoriRemote/Views/ServerFormView.swift b/MoriRemote/MoriRemote/Views/ServerFormView.swift index d11a7d00..787b505c 100644 --- a/MoriRemote/MoriRemote/Views/ServerFormView.swift +++ b/MoriRemote/MoriRemote/Views/ServerFormView.swift @@ -17,6 +17,7 @@ struct ServerFormView: View { let onSave: (Server) -> Void @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var name: String @State private var host: String @@ -49,8 +50,8 @@ struct ServerFormView: View { private var title: String { switch mode { - case .add: return "Add Server" - case .edit: return "Edit Server" + case .add: return String(localized: "Add Server") + case .edit: return String(localized: "Edit Server") } } @@ -62,75 +63,82 @@ struct ServerFormView: View { p > 0 && p <= 65535 } + private var formMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 560 : .infinity + } + var body: some View { NavigationStack { ZStack { Theme.bg.ignoresSafeArea() ScrollView { - VStack(spacing: 20) { - // Name (optional) - fieldSection("LABEL") { - field("My Server", text: $name) + VStack(alignment: .leading, spacing: 18) { + formSummary + + fieldSection(String(localized: "LABEL")) { + field(String(localized: "My Server"), text: $name) } - // Connection - fieldSection("CONNECTION") { - field("hostname or IP", text: $host) + fieldSection(String(localized: "CONNECTION")) { + field(String(localized: "hostname or IP"), text: $host) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - Divider().overlay(Theme.cardBorder) + Divider().overlay(Theme.divider) HStack(spacing: 12) { - Text("Port") + Text(String(localized: "Port")) + .font(.system(size: 13, weight: .medium)) .foregroundStyle(Theme.textSecondary) - .font(.subheadline) + Spacer() - TextField("22", text: $port) + + TextField(String(localized: "22"), text: $port) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) - .frame(width: 80) + .frame(width: 92) + .font(Theme.monoDetailFont) .foregroundStyle(Theme.textPrimary) } .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.vertical, 12) } - // Auth - fieldSection("AUTHENTICATION") { - field("username", text: $username) + fieldSection(String(localized: "AUTHENTICATION")) { + field(String(localized: "username"), text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() - Divider().overlay(Theme.cardBorder) + Divider().overlay(Theme.divider) - SecureField("password", text: $password) + SecureField(String(localized: "password"), text: $password) .padding(.horizontal, 14) .padding(.vertical, 12) .foregroundStyle(Theme.textPrimary) } - // tmux - fieldSection("TMUX SESSION") { - field("main", text: $defaultSession) + fieldSection(String(localized: "TMUX SESSION")) { + field(String(localized: "main"), text: $defaultSession) .textInputAutocapitalization(.never) .autocorrectionDisabled() } - // Save Button { save() } label: { - Text(mode.isAdd ? "Add Server" : "Save Changes") + Text(mode.isAdd ? String(localized: "Add Server") : String(localized: "Save Changes")) } .buttonStyle(Theme.PrimaryButtonStyle(disabled: !isValid)) .disabled(!isValid) - .padding(.top, 4) + .padding(.top, 2) } - .padding(16) - .padding(.bottom, 16) + .frame(maxWidth: formMaxWidth, alignment: .leading) + .padding(.horizontal, Theme.contentInset) + .padding(.top, 16) + .padding(.bottom, 24) + .frame(maxWidth: .infinity) } } .navigationTitle(title) @@ -138,39 +146,50 @@ struct ServerFormView: View { .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .foregroundStyle(Theme.accent) + Button(String(localized: "Cancel")) { dismiss() } + .foregroundStyle(Theme.textSecondary) } } } .presentationDetents([.large]) .presentationDragIndicator(.visible) + .presentationCornerRadius(Theme.sheetRadius) + .presentationBackground(Theme.bg) .preferredColorScheme(.dark) } - // MARK: - Helpers + private var formSummary: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(mode.isAdd + ? String(localized: "Add a server to get started.") + : String(localized: "Review the server settings, then connect when you're ready.")) + .font(.system(size: 13)) + .foregroundStyle(Theme.textSecondary) + } + .cardStyle(padding: 18) + } @ViewBuilder private func fieldSection(_ header: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 8) { Text(header) - .font(.caption.weight(.semibold)) - .foregroundStyle(Theme.textTertiary) - .padding(.leading, 4) + .moriSectionHeaderStyle() + .padding(.leading, 2) VStack(spacing: 0) { content() } - .background(Theme.cardBg, in: RoundedRectangle(cornerRadius: Theme.cardRadius)) - .overlay( - RoundedRectangle(cornerRadius: Theme.cardRadius) - .strokeBorder(Theme.cardBorder, lineWidth: 1) - ) + .cardStyle(padding: 0) } } private func field(_ placeholder: String, text: Binding) -> some View { TextField(placeholder, text: text) + .font(.system(size: 14)) .padding(.horizontal, 14) .padding(.vertical, 12) .foregroundStyle(Theme.textPrimary) @@ -178,6 +197,11 @@ struct ServerFormView: View { private func save() { let portValue = Int(port) ?? 22 + let normalizedDefaultSession = { + let trimmed = defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "main" : trimmed + }() + switch mode { case .add: let server = Server( @@ -186,7 +210,7 @@ struct ServerFormView: View { port: portValue, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password, - defaultSession: defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) + defaultSession: normalizedDefaultSession ) onSave(server) case .edit(var server): @@ -195,7 +219,7 @@ struct ServerFormView: View { server.port = portValue server.username = username.trimmingCharacters(in: .whitespacesAndNewlines) server.password = password - server.defaultSession = defaultSession.trimmingCharacters(in: .whitespacesAndNewlines) + server.defaultSession = normalizedDefaultSession onSave(server) } dismiss() diff --git a/MoriRemote/MoriRemote/Views/ServerListView.swift b/MoriRemote/MoriRemote/Views/ServerListView.swift index 67d1cc0e..21a6fc99 100644 --- a/MoriRemote/MoriRemote/Views/ServerListView.swift +++ b/MoriRemote/MoriRemote/Views/ServerListView.swift @@ -12,34 +12,44 @@ struct ServerListView: View { ZStack { Theme.bg.ignoresSafeArea() - if store.servers.isEmpty { - emptyState - } else { - serverList - } + ServerListContentView( + servers: store.servers, + selectedServerID: nil, + connectingServerID: connectingServerID, + onSelect: connectToServer, + onAdd: { showingAddSheet = true }, + onEdit: { editingServer = $0 }, + onDelete: deleteServer + ) } - .navigationTitle("Servers") - .navigationBarTitleDisplayMode(.large) + .navigationTitle(String(localized: "Servers")) + .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .primaryAction) { Button { showingAddSheet = true } label: { - Image(systemName: "plus.circle.fill") - .font(.title3) - .foregroundStyle(Theme.accent) + Image(systemName: "plus") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 32, height: 32) + .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.rowRadius) + .strokeBorder(Theme.accentBorder, lineWidth: 1) + ) } } } .sheet(isPresented: $showingAddSheet) { ServerFormView(mode: .add) { server in - store.add(server) + addServer(server) } } .sheet(item: $editingServer) { server in ServerFormView(mode: .edit(server)) { updated in - store.update(updated) + updateServer(updated) } } .overlay(alignment: .bottom) { @@ -48,7 +58,7 @@ struct ServerListView: View { coordinator.lastError = nil } .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.horizontal, 16) + .padding(.horizontal, Theme.contentInset) .padding(.bottom, 8) } } @@ -56,66 +66,112 @@ struct ServerListView: View { .preferredColorScheme(.dark) } - // MARK: - Empty State + private var connectingServerID: Server.ID? { + coordinator.state == .connecting ? coordinator.activeServer?.id : nil + } - private var emptyState: some View { - VStack(spacing: 16) { - Image(systemName: "server.rack") - .font(.system(size: 48)) - .foregroundStyle(Theme.textTertiary) + private func connectToServer(_ server: Server) { + guard coordinator.state == .disconnected else { return } + Task { await coordinator.connect(server: server) } + } - Text("No Servers") - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.textPrimary) + private func addServer(_ server: Server) { + coordinator.lastError = nil + store.add(server) + } - Text("Add a server to get started.") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary) + private func updateServer(_ server: Server) { + if coordinator.activeServer?.id == server.id { + coordinator.lastError = nil + } + store.update(server) + } - Button { - showingAddSheet = true - } label: { - Label("Add Server", systemImage: "plus") - } - .buttonStyle(Theme.PrimaryButtonStyle()) - .frame(maxWidth: 220) - .padding(.top, 8) + private func deleteServer(_ server: Server) { + if coordinator.activeServer?.id == server.id { + coordinator.lastError = nil } + store.delete(server) } +} - // MARK: - Server List - - private var serverList: some View { - ScrollView { - LazyVStack(spacing: 10) { - ForEach(store.servers) { server in - ServerRow( - server: server, - isConnecting: coordinator.state == .connecting && coordinator.activeServer?.id == server.id, - onTap: { connectToServer(server) }, - onEdit: { editingServer = server }, - onDelete: { store.delete(server) } - ) +struct ServerListContentView: View { + let servers: [Server] + let selectedServerID: Server.ID? + let connectingServerID: Server.ID? + let onSelect: (Server) -> Void + let onAdd: () -> Void + let onEdit: (Server) -> Void + let onDelete: (Server) -> Void + + var body: some View { + if servers.isEmpty { + ServerListEmptyState(onAdd: onAdd) + } else { + ScrollView { + VStack(alignment: .leading, spacing: Theme.sectionSpacing) { + Text(String(localized: "Servers")) + .moriSectionHeaderStyle() + .padding(.horizontal, Theme.contentInset) + + LazyVStack(spacing: Theme.rowSpacing) { + ForEach(servers) { server in + ServerRow( + server: server, + isSelected: server.id == selectedServerID, + isConnecting: server.id == connectingServerID, + onTap: { onSelect(server) }, + onEdit: { onEdit(server) }, + onDelete: { onDelete(server) } + ) + } + } + .padding(.horizontal, Theme.contentInset) } + .padding(.top, 12) + .padding(.bottom, 32) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 32) } } +} - // MARK: - Connect +private struct ServerListEmptyState: View { + let onAdd: () -> Void - private func connectToServer(_ server: Server) { - guard coordinator.state == .disconnected else { return } - Task { await coordinator.connect(server: server) } + var body: some View { + VStack(spacing: 14) { + Spacer(minLength: 40) + + Image(systemName: "server.rack") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + + Text(String(localized: "No Servers")) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + + Text(String(localized: "Add a server to get started.")) + .font(.system(size: 14)) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + + Button(action: onAdd) { + Label(String(localized: "Add Server"), systemImage: "plus") + } + .buttonStyle(Theme.PrimaryButtonStyle()) + .frame(maxWidth: 220) + .padding(.top, 4) + + Spacer() + } + .padding(.horizontal, 24) } } -// MARK: - Server Row - private struct ServerRow: View { let server: Server + let isSelected: Bool let isConnecting: Bool let onTap: () -> Void let onEdit: () -> Void @@ -125,58 +181,72 @@ private struct ServerRow: View { var body: some View { Button(action: onTap) { - HStack(spacing: 14) { + HStack(spacing: 12) { ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(Theme.accent.opacity(0.12)) - .frame(width: 42, height: 42) + RoundedRectangle(cornerRadius: Theme.rowRadius) + .fill(isSelected ? Theme.accentSoft : Theme.mutedSurface) + .frame(width: 36, height: 36) if isConnecting { ProgressView() .tint(Theme.accent) + .scaleEffect(0.9) } else { Image(systemName: "terminal") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(Theme.accent) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isSelected ? Theme.accent : Theme.textSecondary) } } VStack(alignment: .leading, spacing: 3) { Text(server.displayName) - .font(.body.weight(.medium)) + .font(Theme.rowTitleFont) .foregroundStyle(Theme.textPrimary) .lineLimit(1) Text(server.subtitle) - .font(.caption) + .font(Theme.monoCaptionFont) .foregroundStyle(Theme.textSecondary) .lineLimit(1) } - Spacer() + Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(Theme.textTertiary) + if isConnecting { + Text(String(localized: "Connecting…")) + .font(Theme.shortcutFont) + .foregroundStyle(Theme.accent) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 5)) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Theme.textTertiary) + } } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .rowSurfaceStyle(selected: isSelected) } - .cardStyle() + .buttonStyle(.plain) .contextMenu { Button { onEdit() } label: { - Label("Edit", systemImage: "pencil") + Label(String(localized: "Edit"), systemImage: "pencil") } Button(role: .destructive) { showDeleteConfirm = true } label: { - Label("Delete", systemImage: "trash") + Label(String(localized: "Delete"), systemImage: "trash") } } - .confirmationDialog("Delete \(server.displayName)?", isPresented: $showDeleteConfirm) { - Button("Delete", role: .destructive) { onDelete() } + .confirmationDialog(String( + format: String(localized: "Delete %@?"), + server.displayName + ), isPresented: $showDeleteConfirm) { + Button(String(localized: "Delete"), role: .destructive) { onDelete() } } } } -// MARK: - Error Banner - struct ErrorBanner: View { let message: String let onDismiss: () -> Void @@ -184,26 +254,26 @@ struct ErrorBanner: View { var body: some View { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) + .foregroundStyle(Theme.warning) Text(message) - .font(.subheadline) + .font(.system(size: 13)) .foregroundStyle(Theme.textPrimary) .lineLimit(2) - Spacer() + Spacer(minLength: 8) Button(action: onDismiss) { Image(systemName: "xmark") - .font(.caption.weight(.bold)) + .font(.system(size: 10, weight: .bold)) .foregroundStyle(Theme.textSecondary) } } - .padding(14) - .background(Color(red: 0.15, green: 0.12, blue: 0.08), in: RoundedRectangle(cornerRadius: 12)) + .padding(12) + .background(Color(red: 0.18, green: 0.14, blue: 0.08), in: RoundedRectangle(cornerRadius: Theme.cardRadius)) .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.yellow.opacity(0.25), lineWidth: 1) + RoundedRectangle(cornerRadius: Theme.cardRadius) + .strokeBorder(Theme.warning.opacity(0.24), lineWidth: 1) ) } } diff --git a/MoriRemote/MoriRemote/Views/Sidebar/ServerCardView.swift b/MoriRemote/MoriRemote/Views/Sidebar/ServerCardView.swift index 16e31e0c..11b7691f 100644 --- a/MoriRemote/MoriRemote/Views/Sidebar/ServerCardView.swift +++ b/MoriRemote/MoriRemote/Views/Sidebar/ServerCardView.swift @@ -1,113 +1,110 @@ #if os(iOS) import SwiftUI -/// Server info card at the top of the tmux sidebar. -/// -/// Shows server avatar, name, user@host, connection status dot, -/// and action buttons for "Switch Host" and "Disconnect". +/// Server summary block at the top of the tmux sidebar. struct ServerCardView: View { let server: Server? + let showsDismissButton: Bool let onSwitchHost: () -> Void let onDisconnect: () -> Void let onDismiss: () -> Void var body: some View { - VStack(spacing: 10) { - topRow + VStack(alignment: .leading, spacing: 14) { + header actionButtons } - .padding(12) - .background(Color.white.opacity(0.03), in: RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.white.opacity(0.05), lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.top, 12) + .cardStyle(padding: 16) } - // MARK: - Top Row - - private var topRow: some View { - HStack(spacing: 10) { - // Avatar - RoundedRectangle(cornerRadius: 10) - .fill(Theme.accent.opacity(0.1)) - .frame(width: 36, height: 36) + private var header: some View { + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: Theme.cardRadius) + .fill(server != nil ? Theme.accentSoft : Theme.mutedSurface) + .frame(width: 38, height: 38) .overlay { - Text("🖥") - .font(.system(size: 16)) + Image(systemName: "server.rack") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(server != nil ? Theme.accent : Theme.textSecondary) } - // Server info - VStack(alignment: .leading, spacing: 2) { - Text(server?.displayName ?? "Not Connected") - .font(.system(size: 14, weight: .semibold)) + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Servers")) + .moriSectionHeaderStyle() + + Text(server?.displayName ?? String(localized: "Not Connected")) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.textPrimary) .lineLimit(1) - HStack(spacing: 5) { - Circle() - .fill(server != nil ? Theme.accent : Theme.destructive) - .frame(width: 6, height: 6) - - Text(server?.subtitle ?? "—") - .font(.system(size: 11)) - .foregroundStyle(Theme.textTertiary) - .lineLimit(1) - } + Text(server?.subtitle ?? "—") + .font(Theme.monoCaptionFont) + .foregroundStyle(Theme.textSecondary) + .lineLimit(1) } - Spacer() + Spacer(minLength: 8) - // Close button - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(Theme.textTertiary) - .frame(width: 28, height: 28) - .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 7)) + if showsDismissButton { + HStack(spacing: 8) { + connectionBadge + + Button(action: onDismiss) { + Image(systemName: "sidebar.left") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.textSecondary) + .frame(width: 28, height: 28) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: 7)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) + } + } + } else { + connectionBadge } } } - // MARK: - Action Buttons + private var connectionBadge: some View { + HStack(spacing: 6) { + Circle() + .fill(server != nil ? Theme.success : Theme.destructive) + .frame(width: 6, height: 6) + + Text(server != nil ? String(localized: "Connected") : String(localized: "Offline")) + .font(Theme.shortcutFont) + .foregroundStyle(server != nil ? Theme.success : Theme.destructive) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Theme.cardBorder, lineWidth: 1) + ) + } private var actionButtons: some View { - HStack(spacing: 6) { + HStack(spacing: 8) { Button(action: onSwitchHost) { - HStack(spacing: 5) { - Image(systemName: "arrow.left.arrow.right") - .font(.system(size: 10)) - Text("Switch Host") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(Color.white.opacity(0.5)) - .frame(maxWidth: .infinity) - .frame(height: 30) - .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 7)) - .overlay( - RoundedRectangle(cornerRadius: 7) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) - ) + Label(String(localized: "Switch Host"), systemImage: "arrow.left.arrow.right") } + .buttonStyle(Theme.SecondaryButtonStyle( + foreground: Theme.textSecondary, + background: Theme.mutedSurface, + border: Theme.cardBorder + )) Button(action: onDisconnect) { - HStack(spacing: 5) { - Image(systemName: "power") - .font(.system(size: 10)) - Text("Disconnect") - .font(.system(size: 11, weight: .medium)) - } - .foregroundStyle(Theme.destructive) - .frame(maxWidth: .infinity) - .frame(height: 30) - .background(Theme.destructive.opacity(0.08), in: RoundedRectangle(cornerRadius: 7)) - .overlay( - RoundedRectangle(cornerRadius: 7) - .strokeBorder(Theme.destructive.opacity(0.15), lineWidth: 1) - ) + Label(String(localized: "Disconnect"), systemImage: "power") } + .buttonStyle(Theme.SecondaryButtonStyle( + foreground: Theme.destructive, + background: Theme.destructive.opacity(0.10), + border: Theme.destructive.opacity(0.24) + )) } } } diff --git a/MoriRemote/MoriRemote/Views/Sidebar/TmuxSessionHeader.swift b/MoriRemote/MoriRemote/Views/Sidebar/TmuxSessionHeader.swift index 770fbf8b..927dff22 100644 --- a/MoriRemote/MoriRemote/Views/Sidebar/TmuxSessionHeader.swift +++ b/MoriRemote/MoriRemote/Views/Sidebar/TmuxSessionHeader.swift @@ -1,9 +1,7 @@ #if os(iOS) import SwiftUI -/// Flat uppercase session label with attached badge and long-press context menu. -/// -/// Matches the design mockup's non-expandable session header style. +/// Flat uppercase session label with compact attached badge and context menu actions. struct TmuxSessionHeader: View { let session: TmuxSession let isActive: Bool @@ -12,37 +10,35 @@ struct TmuxSessionHeader: View { let onKill: () -> Void var body: some View { - HStack(spacing: 6) { - Image(systemName: "hexagon") - .font(.system(size: 10)) - .foregroundStyle(Color.white.opacity(0.2)) + HStack(spacing: 8) { + Circle() + .fill(isActive ? Theme.accent : Theme.textTertiary) + .frame(width: 6, height: 6) Text(session.name) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.white.opacity(0.3)) - .textCase(.uppercase) - .tracking(0.5) + .moriSectionHeaderStyle() + .foregroundStyle(isActive ? Theme.textSecondary : Theme.textTertiary) - Spacer() + Spacer(minLength: 8) if session.isAttached { - Text("attached") - .font(.system(size: 10, weight: .medium)) + Text(String(localized: "Connected")) + .font(Theme.shortcutFont) .foregroundStyle(Theme.accent) .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Theme.accent.opacity(0.1), in: RoundedRectangle(cornerRadius: 4)) + .padding(.vertical, 3) + .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 5)) } } - .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 6) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 4) .contentShape(Rectangle()) .contextMenu { Button { onSwitch() } label: { - Label("Switch to Session", systemImage: "arrow.right.square") + Label(String(localized: "Switch to Session"), systemImage: "arrow.right.square") } Divider() @@ -50,7 +46,7 @@ struct TmuxSessionHeader: View { Button { onRename() } label: { - Label("Rename Session", systemImage: "pencil") + Label(String(localized: "Rename Session"), systemImage: "pencil") } Divider() @@ -58,7 +54,7 @@ struct TmuxSessionHeader: View { Button(role: .destructive) { onKill() } label: { - Label("Kill Session", systemImage: "xmark.circle") + Label(String(localized: "Kill Session"), systemImage: "xmark.circle") } } } diff --git a/MoriRemote/MoriRemote/Views/Sidebar/TmuxSidebarFooter.swift b/MoriRemote/MoriRemote/Views/Sidebar/TmuxSidebarFooter.swift index bd31c452..e96183a8 100644 --- a/MoriRemote/MoriRemote/Views/Sidebar/TmuxSidebarFooter.swift +++ b/MoriRemote/MoriRemote/Views/Sidebar/TmuxSidebarFooter.swift @@ -1,21 +1,23 @@ #if os(iOS) import SwiftUI -/// Bottom bar of the tmux sidebar with "+ Window" and "+ Session" buttons. +/// Footer actions for tmux sidebar creation workflows. struct TmuxSidebarFooter: View { let onNewWindow: () -> Void let onNewSession: () -> Void var body: some View { HStack(spacing: 8) { - footerButton(icon: "plus", label: "Window", action: onNewWindow) - footerButton(icon: "plus", label: "Session", action: onNewSession) + footerButton(icon: "plus", label: String(localized: "Window"), action: onNewWindow) + footerButton(icon: "plus", label: String(localized: "Session"), action: onNewSession) } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(alignment: .top) { + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 12) + .background(Theme.sidebarBg) + .overlay(alignment: .top) { Rectangle() - .fill(Color.white.opacity(0.04)) + .fill(Theme.divider) .frame(height: 1) } } @@ -24,19 +26,20 @@ struct TmuxSidebarFooter: View { Button(action: action) { HStack(spacing: 5) { Image(systemName: icon) - .font(.system(size: 12)) + .font(.system(size: 12, weight: .semibold)) Text(label) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .semibold)) } - .foregroundStyle(Color.white.opacity(0.35)) + .foregroundStyle(Theme.textSecondary) .frame(maxWidth: .infinity) .frame(height: 36) - .background(Color.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + .background(Theme.mutedSurface, in: RoundedRectangle(cornerRadius: Theme.rowRadius)) .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color.white.opacity(0.04), lineWidth: 1) + RoundedRectangle(cornerRadius: Theme.rowRadius) + .strokeBorder(Theme.cardBorder, lineWidth: 1) ) } + .buttonStyle(.plain) } } #endif diff --git a/MoriRemote/MoriRemote/Views/Sidebar/TmuxWindowRow.swift b/MoriRemote/MoriRemote/Views/Sidebar/TmuxWindowRow.swift index ebdb445f..516b3918 100644 --- a/MoriRemote/MoriRemote/Views/Sidebar/TmuxWindowRow.swift +++ b/MoriRemote/MoriRemote/Views/Sidebar/TmuxWindowRow.swift @@ -1,10 +1,7 @@ #if os(iOS) import SwiftUI -/// Single tmux window row with left accent bar, index, icon, name, and active dot. -/// -/// Active window shows a 3px teal left bar and highlighted background. -/// Long-press shows a context menu with Switch / New Window After / Close. +/// Compact tmux window row aligned with the Mac sidebar row language. struct TmuxWindowRow: View { let window: TmuxWindow let isActiveSession: Bool @@ -12,7 +9,6 @@ struct TmuxWindowRow: View { let onNewAfter: () -> Void let onClose: () -> Void - /// Only highlight if this window is active AND belongs to the attached session. private var isHighlighted: Bool { window.isActive && isActiveSession } @@ -20,79 +16,47 @@ struct TmuxWindowRow: View { var body: some View { Button(action: onSelect) { HStack(spacing: 10) { - // Window index Text("\(window.index)") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle( - isHighlighted - ? Theme.accent.opacity(0.5) - : Color.white.opacity(0.2) - ) - .frame(width: 16, alignment: .center) + .font(Theme.shortcutFont) + .foregroundStyle(isHighlighted ? Theme.accent : Theme.textTertiary) + .frame(width: 20, alignment: .center) - // Window icon - Image(systemName: isHighlighted ? "square.fill" : "square") - .font(.system(size: 13)) - .foregroundStyle( - isHighlighted ? Theme.accent : Color.white.opacity(0.25) - ) + Image(systemName: window.isActive ? "rectangle.inset.filled" : "rectangle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(isHighlighted ? Theme.accent : Theme.textSecondary) - // Window name + path - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 2) { Text(window.name) - .font(.system(size: 14, weight: isHighlighted ? .semibold : .medium)) - .foregroundStyle( - isHighlighted ? Theme.textPrimary : Color.white.opacity(0.55) - ) + .font(isHighlighted ? Theme.rowTitleFont : .system(size: 13.5, weight: .medium)) + .foregroundStyle(isHighlighted ? Theme.textPrimary : Theme.textSecondary) .lineLimit(1) if !window.shortPath.isEmpty { Text(window.shortPath) - .font(.system(size: 10)) - .foregroundStyle( - isHighlighted - ? Theme.accent.opacity(0.5) - : Color.white.opacity(0.2) - ) + .font(Theme.monoCaptionFont) + .foregroundStyle(isHighlighted ? Theme.textSecondary : Theme.textTertiary) .lineLimit(1) } } - Spacer() + Spacer(minLength: 8) - // Active dot if isHighlighted { Circle() .fill(Theme.accent) - .frame(width: 5, height: 5) + .frame(width: 6, height: 6) } } - .padding(.horizontal, 16) - .frame(minHeight: 44) - .background( - isHighlighted - ? Theme.accent.opacity(0.08) - : Color.clear - ) - // Left accent bar for active window - .overlay(alignment: .leading) { - if isHighlighted { - RoundedRectangle(cornerRadius: 2) - .fill(Theme.accent) - .frame(width: 3) - .padding(.vertical, 8) - } - } - .contentShape(Rectangle()) + .padding(.horizontal, 10) + .padding(.vertical, 9) + .rowSurfaceStyle(selected: isHighlighted) } .buttonStyle(.plain) - .padding(.horizontal, 8) - .clipShape(RoundedRectangle(cornerRadius: 8)) .contextMenu { Button { onSelect() } label: { - Label("Switch to Window", systemImage: "arrow.up.right.square") + Label(String(localized: "Switch to Window"), systemImage: "arrow.up.right.square") } Divider() @@ -100,7 +64,7 @@ struct TmuxWindowRow: View { Button { onNewAfter() } label: { - Label("New Window After", systemImage: "plus.rectangle") + Label(String(localized: "New Window After"), systemImage: "plus.rectangle") } Divider() @@ -108,7 +72,7 @@ struct TmuxWindowRow: View { Button(role: .destructive) { onClose() } label: { - Label("Close Window", systemImage: "xmark.circle") + Label(String(localized: "Close Window"), systemImage: "xmark.circle") } } } diff --git a/MoriRemote/MoriRemote/Views/SidebarContainer.swift b/MoriRemote/MoriRemote/Views/SidebarContainer.swift index 18f4c1dc..ba0e4a97 100644 --- a/MoriRemote/MoriRemote/Views/SidebarContainer.swift +++ b/MoriRemote/MoriRemote/Views/SidebarContainer.swift @@ -2,16 +2,11 @@ import SwiftUI /// Container that adds a slide-from-left sidebar overlay to terminal content. -/// -/// The sidebar can be opened by: -/// - Swiping from the left edge of the content area -/// - Tapping the sidebar button -/// - Setting `isOpen` to true struct SidebarContainer: View { @Binding var isOpen: Bool let content: Content - private let sidebarWidth: CGFloat = 280 + private let sidebarWidth: CGFloat = 300 let sidebar: () -> Sidebar @@ -25,14 +20,12 @@ struct SidebarContainer: View { @GestureState private var isDragging = false var body: some View { - GeometryReader { geo in + GeometryReader { _ in ZStack(alignment: .leading) { - // Main content — carries the edge-open gesture content .frame(maxWidth: .infinity, maxHeight: .infinity) .gesture(edgeOpenGesture) - // Dimming overlay — carries the close gesture if isOpen || isDragging { Color.black .opacity(dimmingOpacity) @@ -42,7 +35,6 @@ struct SidebarContainer: View { .allowsHitTesting(isOpen) } - // Sidebar panel — NO gesture, so ScrollView works freely if isOpen || isDragging { sidebarPanel .frame(width: sidebarWidth) @@ -53,24 +45,24 @@ struct SidebarContainer: View { } } - // MARK: - Sidebar Panel - private var sidebarPanel: some View { sidebar() .clipShape( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 0, - bottomTrailingRadius: 16, - topTrailingRadius: 16 + bottomTrailingRadius: 12, + topTrailingRadius: 12 ) ) - .shadow(color: .black.opacity(0.5), radius: 20, x: 5) + .overlay(alignment: .trailing) { + Rectangle() + .fill(Theme.divider) + .frame(width: 1) + } + .shadow(color: .black.opacity(0.28), radius: 18, x: 8) } - // MARK: - Gestures - - /// Edge swipe from left to open the sidebar (only on content area). private var edgeOpenGesture: some Gesture { DragGesture(minimumDistance: 15, coordinateSpace: .global) .updating($isDragging) { _, state, _ in @@ -93,7 +85,6 @@ struct SidebarContainer: View { } } - /// Swipe left on dimming overlay to close the sidebar. private var closeGesture: some Gesture { DragGesture(minimumDistance: 15, coordinateSpace: .global) .onChanged { value in @@ -113,30 +104,24 @@ struct SidebarContainer: View { } } - // MARK: - Animation Helpers - private var sidebarOffset: CGFloat { - if isOpen { - return dragOffset - } else { - return dragOffset - } + dragOffset } private var dimmingOpacity: Double { let progress = 1.0 + Double(dragOffset) / Double(sidebarWidth) - return 0.4 * max(0, min(1, progress)) + return 0.34 * max(0, min(1, progress)) } private func open() { - withAnimation(.spring(duration: 0.3, bounce: 0.0)) { + withAnimation(.easeOut(duration: 0.18)) { isOpen = true dragOffset = 0 } } private func close() { - withAnimation(.spring(duration: 0.25, bounce: 0.0)) { + withAnimation(.easeOut(duration: 0.16)) { isOpen = false dragOffset = 0 } diff --git a/MoriRemote/MoriRemote/Views/TerminalScreen.swift b/MoriRemote/MoriRemote/Views/TerminalScreen.swift index cb13a3de..0fa43508 100644 --- a/MoriRemote/MoriRemote/Views/TerminalScreen.swift +++ b/MoriRemote/MoriRemote/Views/TerminalScreen.swift @@ -2,120 +2,277 @@ import MoriTerminal import SwiftUI struct TerminalScreen: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(ShellCoordinator.self) private var coordinator + let sessionHost: TerminalSessionHost let serverName: String let onDisconnect: () -> Void + let onSwitchHost: () -> Void - @State private var shellStarted = false - @State private var showKeyBarCustomize = false - @State private var renderer: SwiftTermRendererBox? - @State private var accessoryBar = TerminalAccessoryBar() @State private var showSidebar = false + @State private var showRegularSidebar = true var body: some View { - SidebarContainer(isOpen: $showSidebar) { - TmuxSidebarView( - onDismiss: { showSidebar = false }, - onDisconnect: onDisconnect, - onSwitchHost: onDisconnect - ) - } content: { - terminalContent + Group { + if horizontalSizeClass == .regular { + regularWorkspace + } else { + compactWorkspace + } } .statusBarHidden(true) .preferredColorScheme(.dark) - .sheet(isPresented: $showKeyBarCustomize) { - KeyBarCustomizeView(keyBar: accessoryBar.keyBar) + .sheet(isPresented: keyBarCustomizeBinding) { + KeyBarCustomizeView(keyBar: sessionHost.accessoryBar.keyBar) .presentationDetents([.medium, .large]) } + .confirmationDialog( + String(localized: "Tmux Shortcuts"), + isPresented: tmuxCommandsBinding, + titleVisibility: .visible + ) { + Button(String(localized: "New Tab")) { coordinator.handleTmuxCommand(.newWindow) } + Button(String(localized: "Next Tab")) { coordinator.handleTmuxCommand(.nextWindow) } + Button(String(localized: "Previous Tab")) { coordinator.handleTmuxCommand(.prevWindow) } + Button(String(localized: "Split Right")) { coordinator.handleTmuxCommand(.splitRight) } + Button(String(localized: "Split Down")) { coordinator.handleTmuxCommand(.splitDown) } + Button(String(localized: "Next Pane")) { coordinator.handleTmuxCommand(.nextPane) } + Button(String(localized: "Previous Pane")) { coordinator.handleTmuxCommand(.prevPane) } + Button(String(localized: "Toggle Zoom")) { coordinator.handleTmuxCommand(.toggleZoom) } + Button(String(localized: "Close Pane"), role: .destructive) { coordinator.handleTmuxCommand(.closePane) } + Button(String(localized: "Detach"), role: .destructive) { coordinator.handleTmuxCommand(.detach) } + Button(String(localized: "Cancel"), role: .cancel) { } + } + .onAppear { + sessionHost.accessoryBar.onBackTapped = onSwitchHost + sessionHost.handleCoordinatorStateChange( + coordinator.state, + activeServerID: coordinator.activeServer?.id + ) + } .onChange(of: coordinator.state) { _, newState in - if newState == .shell { - renderer?.value.activateKeyboard() + sessionHost.handleCoordinatorStateChange( + newState, + activeServerID: coordinator.activeServer?.id + ) + + if newState != .shell { + showSidebar = false + } + } + .onChange(of: horizontalSizeClass) { _, newSizeClass in + if newSizeClass == .regular { + showSidebar = false } } } - // MARK: - Terminal Content + private var compactWorkspace: some View { + SidebarContainer(isOpen: $showSidebar) { + sidebarContent(presentation: .overlay, onDismiss: { showSidebar = false }) + } content: { + terminalContent(showsCompactChrome: true) + } + } - private var terminalContent: some View { - ZStack { - Color.black.ignoresSafeArea() + private var regularWorkspace: some View { + HStack(spacing: 0) { + if showRegularSidebar { + sidebarContent( + presentation: .persistent, + onDismiss: { showRegularSidebar = false } + ) + .frame(width: 304) + .background(Theme.sidebarBg) - TerminalView( - onRendererReady: { r in - renderer = SwiftTermRendererBox(r) + Rectangle() + .fill(Theme.divider) + .frame(width: 1) + } - guard !shellStarted else { return } + terminalContent(showsCompactChrome: false) + } + .safeAreaInset(edge: .top, alignment: .leading) { + if coordinator.state == .shell && !showRegularSidebar { + HStack { + regularSidebarRevealButton + Spacer(minLength: 0) + } + .padding(.top, 6) + .padding(.leading, 12) + .padding(.trailing, 12) + } + } + .background(Theme.terminalBg.ignoresSafeArea()) + } - r.initialLayoutHandler = { [weak r] cols, rows in - guard let r else { return } - startShellOnce(renderer: r) - } + private func sidebarContent( + presentation: TmuxSidebarPresentation, + onDismiss: (() -> Void)? + ) -> some View { + TmuxSidebarView( + presentation: presentation, + onDismiss: onDismiss, + onDisconnect: onDisconnect, + onSwitchHost: onSwitchHost + ) + } - let size = r.gridSize() - if size.cols > 0 && size.rows > 0 { - startShellOnce(renderer: r) - } + private var keyBarCustomizeBinding: Binding { + Binding( + get: { sessionHost.showKeyBarCustomize }, + set: { sessionHost.showKeyBarCustomize = $0 } + ) + } + + private var tmuxCommandsBinding: Binding { + Binding( + get: { sessionHost.showTmuxCommands }, + set: { sessionHost.showTmuxCommands = $0 } + ) + } + + private func terminalContent(showsCompactChrome: Bool) -> some View { + ZStack { + Theme.terminalBg.ignoresSafeArea() + + TerminalView( + onRendererReady: { renderer in + sessionHost.handleRendererReady(renderer, coordinator: coordinator) } ) .ignoresSafeArea(.container, edges: .bottom) if coordinator.state != .shell { - VStack(spacing: 14) { - ProgressView() - .tint(Theme.accent) - .scaleEffect(1.2) + TerminalPreparingOverlay( + serverName: serverName, + subtitle: coordinator.activeServer?.subtitle ?? "", + sessionName: coordinator.activeServer?.defaultSession ?? "" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + } + + private var regularSidebarRevealButton: some View { + Button { + showRegularSidebar = true + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) + .frame(width: 32, height: 32) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + ) + } +} + +private struct TerminalPreparingOverlay: View { + let serverName: String + let subtitle: String + let sessionName: String + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + RoundedRectangle(cornerRadius: Theme.cardRadius) + .fill(Theme.accentSoft) + .frame(width: 42, height: 42) + .overlay { + ProgressView() + .tint(Theme.accent) + } + + VStack(alignment: .leading, spacing: 6) { + TerminalStateBadge(title: String(localized: "SSH Connected")) + + Text(String(localized: "Preparing Terminal")) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) - Text("Opening shell…") - .font(.subheadline.weight(.medium)) + Text(serverName) + .font(Theme.rowTitleFont) .foregroundStyle(Theme.textSecondary) + + if !subtitle.isEmpty { + Text(subtitle) + .font(Theme.monoDetailFont) + .foregroundStyle(Theme.textSecondary) + } } - .padding(24) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) } - } - .overlay(alignment: .topLeading) { - if coordinator.state == .shell && !showSidebar { - sidebarButton + VStack(spacing: 0) { + terminalMetadataRow(label: String(localized: "Session"), value: sessionName, monospace: true) + Rectangle() + .fill(Theme.divider) + .frame(height: 1) + terminalMetadataRow(label: String(localized: "Status"), value: String(localized: "Opening shell…")) } - } - } + .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: Theme.cardRadius)) + .overlay( + RoundedRectangle(cornerRadius: Theme.cardRadius) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) + ) - private func startShellOnce(renderer r: SwiftTermRenderer) { - guard !shellStarted else { return } - shellStarted = true - coordinator.accessoryBar = accessoryBar - accessoryBar.onCustomizeTapped = { - showKeyBarCustomize = true - } - Task { - await coordinator.openShell(renderer: r) + Text(String(localized: "Opening the interactive shell and checking tmux windows.")) + .font(.system(size: 14)) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) } + .padding(20) + .frame(maxWidth: 360, alignment: .leading) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.white.opacity(0.12), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.18), radius: 18, y: 8) + .padding(.horizontal, 24) } - // MARK: - Sidebar Button + private func terminalMetadataRow(label: String, value: String, monospace: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.textSecondary) - private var sidebarButton: some View { - Button { - showSidebar = true - } label: { - Image(systemName: "sidebar.left") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.white.opacity(0.7)) - .frame(width: 44, height: 44) - .background(.ultraThinMaterial, in: Circle()) + Spacer(minLength: 8) + + Text(value) + .font(monospace ? Theme.monoDetailFont : .system(size: 13)) + .foregroundStyle(Theme.textPrimary) + .multilineTextAlignment(.trailing) } - .padding(.leading, 12) - .padding(.top, 8) + .padding(.horizontal, 14) + .padding(.vertical, 12) } +} +private struct TerminalStateBadge: View { + let title: String -} + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(Theme.accent) + .frame(width: 6, height: 6) -@MainActor -private final class SwiftTermRendererBox { - let value: SwiftTermRenderer - init(_ value: SwiftTermRenderer) { self.value = value } + Text(title) + .font(Theme.shortcutFont.weight(.semibold)) + .foregroundStyle(Theme.accent) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Theme.accentSoft, in: RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Theme.accentBorder, lineWidth: 1) + ) + } } diff --git a/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift b/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift new file mode 100644 index 00000000..54859891 --- /dev/null +++ b/MoriRemote/MoriRemote/Views/TerminalSessionHost.swift @@ -0,0 +1,114 @@ +import MoriTerminal +import Observation +import SwiftUI + +@MainActor +@Observable +final class TerminalSessionHost { + var showKeyBarCustomize = false + var showTmuxCommands = false + let accessoryBar = TerminalAccessoryBar() + + private weak var renderer: SwiftTermRenderer? + private var hostedSessionState: HostedSessionState = .idle + private var lastOpenRequest: ShellOpenRequest? + + func handleRendererReady(_ renderer: SwiftTermRenderer, coordinator: ShellCoordinator) { + self.renderer = renderer + coordinator.accessoryBar = accessoryBar + accessoryBar.onCustomizeTapped = { [weak self] in + self?.showKeyBarCustomize = true + } + accessoryBar.onTmuxMenuTapped = { [weak self] in + self?.showTmuxCommands = true + } + + renderer.initialLayoutHandler = { [weak self, weak renderer] _, _ in + guard let self, let renderer else { return } + self.openShellIfNeeded(with: renderer, coordinator: coordinator) + } + + let size = renderer.gridSize() + if size.cols > 0 && size.rows > 0 { + openShellIfNeeded(with: renderer, coordinator: coordinator) + } + } + + func handleCoordinatorStateChange(_ state: ShellState, activeServerID: Server.ID?) { + switch state { + case .disconnected: + hostedSessionState = .idle + lastOpenRequest = nil + renderer?.initialLayoutHandler = nil + renderer = nil + showKeyBarCustomize = false + showTmuxCommands = false + + case .connecting: + if hostedSessionState.serverID != activeServerID { + hostedSessionState = .idle + lastOpenRequest = nil + } + showKeyBarCustomize = false + showTmuxCommands = false + + case .connected: + hostedSessionState = activeServerID.map(HostedSessionState.waitingForShell) ?? .idle + if lastOpenRequest?.serverID != activeServerID { + lastOpenRequest = nil + } + showKeyBarCustomize = false + showTmuxCommands = false + + case .shell: + hostedSessionState = activeServerID.map(HostedSessionState.shellOpen) ?? .idle + renderer?.activateKeyboard() + } + } + + private func openShellIfNeeded(with renderer: SwiftTermRenderer, coordinator: ShellCoordinator) { + guard let activeServerID = coordinator.activeServer?.id else { return } + + let request = ShellOpenRequest(serverID: activeServerID, rendererID: ObjectIdentifier(renderer)) + guard lastOpenRequest != request else { return } + + switch coordinator.state { + case .connected: + if hostedSessionState.serverID != activeServerID { + hostedSessionState = .waitingForShell(activeServerID) + } + lastOpenRequest = request + renderer.initialLayoutHandler = nil + Task { await coordinator.openShell(renderer: renderer) } + + case .shell: + hostedSessionState = .shellOpen(activeServerID) + lastOpenRequest = request + renderer.initialLayoutHandler = nil + Task { await coordinator.openShell(renderer: renderer) } + + case .connecting, .disconnected: + break + } + } +} + +private struct ShellOpenRequest: Equatable { + let serverID: Server.ID + let rendererID: ObjectIdentifier +} + +private enum HostedSessionState: Equatable { + case idle + case waitingForShell(Server.ID) + case shellOpen(Server.ID) + + var serverID: Server.ID? { + switch self { + case .idle: + nil + case .waitingForShell(let serverID), .shellOpen(let serverID): + serverID + } + } +} diff --git a/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift b/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift index b578bd4b..3e8a1fa8 100644 --- a/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift +++ b/MoriRemote/MoriRemote/Views/TmuxSidebarView.swift @@ -1,41 +1,58 @@ #if os(iOS) import SwiftUI -/// Slide-over sidebar showing server info and tmux sessions/windows. -/// -/// Layout matches the design mockup: -/// - Server card at top (name, status, Switch Host / Disconnect) -/// - Flat session labels (uppercase, always expanded) with windows -/// - Footer with + Window / + Session buttons -/// - Long-press context menus on sessions and windows +enum TmuxSidebarPresentation { + case overlay + case persistent + + var showsDismissButton: Bool { + self == .overlay + } + + var dismissesAfterSelection: Bool { + self == .overlay + } +} + +/// Sidebar showing the active server and tmux sessions/windows. struct TmuxSidebarView: View { @Environment(ShellCoordinator.self) private var coordinator - let onDismiss: () -> Void + let presentation: TmuxSidebarPresentation + let onDismiss: (() -> Void)? let onDisconnect: () -> Void - var onSwitchHost: (() -> Void)? + let onSwitchHost: () -> Void @State private var renameTarget: TmuxSession? @State private var renameText = "" var body: some View { VStack(spacing: 0) { - ServerCardView( - server: coordinator.activeServer, - onSwitchHost: { onSwitchHost?() }, - onDisconnect: { - onDismiss() - onDisconnect() - }, - onDismiss: onDismiss - ) - - divider - - if coordinator.tmuxSessions.isEmpty { - emptyState - } else { - sessionList + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ServerCardView( + server: coordinator.activeServer, + showsDismissButton: onDismiss != nil, + onSwitchHost: onSwitchHost, + onDisconnect: onDisconnect, + onDismiss: { onDismiss?() } + ) + + VStack(alignment: .leading, spacing: 10) { + Text(String(localized: "Session")) + .moriSectionHeaderStyle() + .padding(.horizontal, 16) + + if coordinator.tmuxSessions.isEmpty { + emptyState + } else { + sessionList + } + } + } + .padding(.horizontal, 10) + .padding(.top, 12) + .padding(.bottom, 18) } if !coordinator.tmuxSessions.isEmpty { @@ -46,11 +63,11 @@ struct TmuxSidebarView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(red: 0.07, green: 0.07, blue: 0.10)) - .alert("Rename Session", isPresented: showRenameAlert) { - TextField("Session name", text: $renameText) - Button("Cancel", role: .cancel) { } - Button("Rename") { + .background(Theme.sidebarBg) + .alert(String(localized: "Rename Session"), isPresented: showRenameAlert) { + TextField(String(localized: "Session name"), text: $renameText) + Button(String(localized: "Cancel"), role: .cancel) { } + Button(String(localized: "Rename")) { if let session = renameTarget, !renameText.isEmpty { coordinator.renameTmuxSession(session.name, to: renameText) } @@ -58,28 +75,19 @@ struct TmuxSidebarView: View { } } - // MARK: - Subviews - - private var divider: some View { - Rectangle() - .fill(Color.white.opacity(0.04)) - .frame(height: 1) - .padding(.horizontal, 16) - .padding(.top, 12) - } - private var sessionList: some View { - ScrollView { - LazyVStack(spacing: 1) { - ForEach(coordinator.tmuxSessions) { session in - let isActive = session.name == coordinator.tmuxActiveSession?.name + LazyVStack(spacing: 10) { + ForEach(coordinator.tmuxSessions) { session in + let isActive = session.name == coordinator.tmuxActiveSession?.name + let isActiveSession = session.name == coordinator.tmuxActiveSession?.name + VStack(spacing: 6) { TmuxSessionHeader( session: session, isActive: isActive, onSwitch: { coordinator.switchTmuxSession(session.name) - onDismiss() + dismissIfNeeded() }, onRename: { renameTarget = session @@ -90,88 +98,85 @@ struct TmuxSidebarView: View { } ) - let isActiveSession = session.name == coordinator.tmuxActiveSession?.name - - ForEach(session.windows) { window in - TmuxWindowRow( - window: window, - isActiveSession: isActiveSession, - onSelect: { - coordinator.selectTmuxWindow( - session: session.name, - windowIndex: window.index - ) - onDismiss() - }, - onNewAfter: { - coordinator.newTmuxWindowAfter( - session: session.name, - windowIndex: window.index - ) - }, - onClose: { - coordinator.closeTmuxWindow( - session: session.name, - windowIndex: window.index - ) - } - ) + VStack(spacing: 4) { + ForEach(session.windows) { window in + TmuxWindowRow( + window: window, + isActiveSession: isActiveSession, + onSelect: { + coordinator.selectTmuxWindow( + session: session.name, + windowIndex: window.index + ) + dismissIfNeeded() + }, + onNewAfter: { + coordinator.newTmuxWindowAfter( + session: session.name, + windowIndex: window.index + ) + }, + onClose: { + coordinator.closeTmuxWindow( + session: session.name, + windowIndex: window.index + ) + } + ) + } } + .padding(.horizontal, 8) + .padding(.bottom, 8) } + .cardStyle(padding: 0) } - .padding(.top, 4) } } private var emptyState: some View { - VStack(spacing: 10) { - Spacer() - + VStack(alignment: .center, spacing: 10) { Image(systemName: "square.grid.2x2") - .font(.system(size: 28)) - .foregroundStyle(Color.white.opacity(0.1)) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) - Text("No tmux sessions") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white.opacity(0.25)) + Text(String(localized: "No tmux sessions")) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary) - Text("Start tmux to manage\nwindows from here.") + Text(String(localized: "Start tmux to manage\nwindows from here.")) .font(.system(size: 12)) - .foregroundStyle(Color.white.opacity(0.15)) + .foregroundStyle(Theme.textSecondary) .multilineTextAlignment(.center) Button { coordinator.newTmuxSession() } label: { - HStack(spacing: 4) { - Image(systemName: "plus") - .font(.system(size: 11)) - Text("New Session") - .font(.system(size: 13, weight: .semibold)) - } - .foregroundStyle(Theme.accent) - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(Theme.accent.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Theme.accent.opacity(0.15), lineWidth: 1) - ) + Label(String(localized: "New Session"), systemImage: "plus") } - .padding(.top, 8) - - Spacer() + .buttonStyle(Theme.SecondaryButtonStyle( + foreground: Theme.accent, + background: Theme.accentSoft, + border: Theme.accentBorder + )) + .frame(maxWidth: 180) + .padding(.top, 2) } - .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18) + .padding(.vertical, 26) + .cardStyle(padding: 20) } - // MARK: - Rename Alert Binding - private var showRenameAlert: Binding { Binding( get: { renameTarget != nil }, set: { if !$0 { renameTarget = nil } } ) } + + private func dismissIfNeeded() { + guard presentation.dismissesAfterSelection else { return } + onDismiss?() + } } #endif diff --git a/README.md b/README.md index 37afcf67..3bc81909 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Instead of managing loose terminal tabs, Mori treats your git repositories as fi - **Project-first navigation** — switch between repos and branches, not anonymous tabs - **Local + remote projects** — add local folders or SSH-hosted repositories from one Add flow - **Persistent sessions** — close the app, reopen later, everything is still running in tmux +- **Remote companion on iPhone and iPad** — MoriRemote gives you adaptive SSH/tmux access away from your Mac - **Native macOS experience** — sidebar, command palette, notifications, keyboard shortcuts - **GPU-rendered terminal** — libghostty (Ghostty's rendering engine) with Metal acceleration - **Worktree-aware** — multiple branches of the same repo run side-by-side with independent sessions diff --git a/README.zh-Hans.md b/README.zh-Hans.md index cb0b3cfa..dc253cbd 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -15,6 +15,7 @@ Mori 不再让你管理零散的终端标签页,而是将 git 仓库视为一 - **项目优先导航** — 在仓库和分支间切换,而非匿名标签页 - **本地 + 远程项目** — 在同一个添加流程中支持本地目录与 SSH 远程仓库 - **持久化会话** — 关闭应用后重新打开,一切仍在 tmux 中运行 +- **iPhone / iPad 远程伴侣** — 通过 MoriRemote 在离开 Mac 时也能获得自适应 SSH / tmux 访问体验 - **原生 macOS 体验** — 侧边栏、命令面板、通知、键盘快捷键 - **GPU 渲染终端** — libghostty(Ghostty 的渲染引擎)搭配 Metal 加速 - **工作树感知** — 同一仓库的多个分支可并行运行,各自拥有独立会话 diff --git a/vendor/ghostty b/vendor/ghostty index 6057f8d2..01825411 160000 --- a/vendor/ghostty +++ b/vendor/ghostty @@ -1 +1 @@ -Subproject commit 6057f8d2b75631937fa7c2fc240a8bbe9137176f +Subproject commit 01825411ab2720e47e6902e9464e805bc6a062a1