diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index f5d01170d..e47ab9e5a 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -1,67 +1,71 @@ # Architecture -Architectural decisions, patterns discovered, and design principles. +Architectural decisions, patterns discovered, and conventions. -**What belongs here:** Architectural patterns, data flow, component organization, design decisions. +**What belongs here:** Architectural patterns, component relationships, design decisions. --- -## iOS App Architecture +## iOS App Structure -### Pattern: MVVM-like with Shared Service -- `SyncService` is the shared `@MainActor ObservableObject` injected via `.environmentObject()` -- Views own local `@State` for UI concerns -- Views call into `SyncService` for remote operations and data fetching -- Data flow: `SyncService` → `DatabaseService` (SQLite) → `localStateRevision` increment → SwiftUI reactivity via `.task(id: syncService.localStateRevision)` - -### File Structure ``` -ADE/ +apps/ios/ADE/ ├── App/ -│ ├── ADEApp.swift # App entry point, UIKit theme config -│ └── ContentView.swift # Root TabView, Settings tab, design system components -├── Views/ -│ ├── LanesTabView.swift # ~3,706 lines - complete -│ ├── FilesTabView.swift # ~500 lines - baseline -│ ├── WorkTabView.swift # ~300 lines - baseline -│ └── PRsTabView.swift # ~500 lines - baseline +│ ├── ADEApp.swift # @main entry, scene setup +│ └── ContentView.swift # TabView with 5 tabs: Lanes, Files, Work, PRs, Settings ├── Models/ -│ └── RemoteModels.swift # ~700 lines - all domain models +│ └── RemoteModels.swift # All data models for WebSocket communication ├── Services/ -│ ├── Database.swift # ~1,949 lines - SQLite + cr-sqlite sync -│ ├── KeychainService.swift # ~50 lines - token persistence -│ └── SyncService.swift # ~1,781 lines - WebSocket + Bonjour + RPC -└── Resources/ - └── DatabaseBootstrap.sql # ~2,260 lines - full schema +│ ├── SyncService.swift # WebSocket client, all API calls to desktop +│ ├── Database.swift # CRSQLite local database +│ └── KeychainService.swift # Secure credential storage +├── Views/ +│ ├── Components/ +│ │ ├── ADEDesignSystem.swift # Glass morphism, semantic colors, motion system +│ │ └── FilesCodeSupport.swift # Syntax highlighting (13 languages), language detection +│ ├── Files/ +│ │ ├── FilesTabView.swift # Root tab: workspace picker, navigation shell +│ │ ├── FileTreeView.swift # Directory screen, tree rows, breadcrumbs +│ │ ├── FileTreeViewModel.swift # Tree state, expand/collapse, child loading +│ │ ├── FileOperationsHelper.swift # Shared types, path helpers, validation +│ │ ├── FileSearchView.swift # Search sheet UI, result rows +│ │ ├── FileSearchViewModel.swift # Debounced quick-open and text search +│ │ ├── FileViewerView.swift # File editor/viewer screen +│ │ ├── FileViewerViewModel.swift # Load, save, diff, find/replace state +│ │ ├── FileViewerChromeViews.swift # Header, mode control, info sheet +│ │ ├── FileViewerCodeEditorView.swift # UITextView code editor with gutter +│ │ ├── FileViewerHelpers.swift # Pure functions: line numbers, find/replace +│ │ └── FileViewerRenderingViews.swift # Binary preview, syntax view, diff, image +│ ├── LanesTabView.swift +│ ├── PRsTabView.swift +│ └── WorkTabView.swift +├── Resources/ +│ └── DatabaseBootstrap.sql +├── Assets.xcassets +└── Info.plist ``` -### Database -- Direct SQLite3 C API (no ORM) -- cr-sqlite change tracking with custom triggers (insert/update/delete) -- Bidirectional changeset sync via WebSocket -- Site ID management (persistent 128-bit random) -- Full bootstrap SQL schema (~2,260 lines) mirroring desktop +## Communication Architecture + +The iOS app communicates with the desktop over WebSocket using a typed envelope protocol: + +1. **file_request / file_response** — File operations (listTree, readFile, writeText, createFile, createDirectory, rename, deletePath, quickOpen, searchText) +2. **command / command_ack / command_result** — Git operations and atomic writes + +All API calls go through `SyncService.swift` methods. Workers must NOT create new API calls — only use existing methods. -### Networking -- Raw `URLSessionWebSocketTask` — no third-party dependencies -- JSON envelopes with optional gzip compression (>4KB) -- Heartbeat ping/pong protocol -- Auto-reconnect with exponential backoff -- Bonjour (`NetServiceBrowser`) for LAN discovery -- Connection-scoped async work in `SyncService` must be tied to the active socket/session: store long-lived tasks so `disconnect()` and host switching can cancel them, and ignore stale send/receive callbacks unless they still belong to the current `socket` +## Key Data Models (RemoteModels.swift) -### Command Routing -- State-only operations: write locally → cr-sqlite syncs to host -- Execution operations: send command via WebSocket → host executes → state syncs back -- Offline command queue: persisted to UserDefaults, flushed on reconnect +- `FileTreeNode` — { name, path, type, hasChildren, children, changeStatus, size } +- `SyncFileBlob` — { path, size, mimeType, encoding, isBinary, content, languageId } +- `FilesWorkspace` — { id, kind, laneId, name, rootPath, isReadOnlyByDefault } +- `FilesQuickOpenItem` — { path, score } +- `FilesSearchTextMatch` — { path, line, column, preview } -### Key Model Types (RemoteModels.swift) -- `RemoteLane`, `RemoteLaneDetail`, `LaneStateSnapshot` -- `RemoteTerminalSession`, `SessionHistoryEntry` -- `PullRequestRow`, `PullRequestSnapshot`, `PRDetailPayload` -- `RemoteFileNode`, `RemoteSearchResult` -- `ChatMessage`, `ToolCallResult` +## Design System (ADEDesignSystem.swift) -### Adding New Swift Files -New .swift files MUST be added to the Xcode project by editing `ADE.xcodeproj/project.pbxproj`. -Both `PBXFileReference` and `PBXSourcesBuildPhase` sections need entries. +- iOS 26 liquid glass effects via `.glassEffect()` modifiers +- Semantic color tokens: `adeAccent`, `adeSecondaryText`, `adeBackground`, etc. +- Motion system with spring animations +- Glass card component for grouped content +- Workers should use these tokens, not hard-coded colors diff --git a/.factory/services.yaml b/.factory/services.yaml index d7f38571c..aca918c40 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -1,5 +1,7 @@ commands: - build: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build build - test: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build test - typecheck: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build build - lint: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build analyze + build: xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -5 + test: xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -configuration Debug CODE_SIGNING_ALLOWED=NO 2>&1 | tail -40 + build_full: xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -configuration Debug CODE_SIGNING_ALLOWED=NO + test_full: xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' -configuration Debug CODE_SIGNING_ALLOWED=NO + +services: {} diff --git a/.gitignore b/.gitignore index 8e845314a..736814072 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ *.db # Build outputs +build/ /apps/mcp-server/dist/ /apps/desktop/release/ /apps/desktop/dist/ diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index a055b87ef..1d7cfff48 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -18,6 +18,17 @@ 60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; }; 63A9C60B0E0F0E2707634B2E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8943C47805A871A4E4A4BF68 /* Assets.xcassets */; }; 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */; }; + 7A1B2C3D4E5F60718293A4B5 /* FileViewerChromeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4B4 /* FileViewerChromeViews.swift */; }; + 7B2C3D4E5F60718293A4B5C6 /* FileViewerCodeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2C3D4E5F60718293A4B5C5 /* FileViewerCodeEditorView.swift */; }; + 7C3D4E5F60718293A4B5C6D7 /* FileViewerHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C3D4E5F60718293A4B5C6D6 /* FileViewerHelpers.swift */; }; + 7D4E5F60718293A4B5C6D7E8 /* FileViewerRenderingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D4E5F60718293A4B5C6D7E7 /* FileViewerRenderingViews.swift */; }; + A1B2C3D4E5F60718A9B0C1D3 /* FileOperationsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718A9B0C1D2 /* FileOperationsHelper.swift */; }; + B2C3D4E5F6071829A0B1C2D4 /* FileTreeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6071829A0B1C2D3 /* FileTreeViewModel.swift */; }; + C3D4E5F607182930B1C2D3E5 /* FileViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F607182930B1C2D3E4 /* FileViewerViewModel.swift */; }; + D4E5F6071829304AC2D3E4F6 /* FileSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6071829304AC2D3E4F5 /* FileSearchViewModel.swift */; }; + E5F60718293041B5D3E4F507 /* FileTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F60718293041B5D3E4F506 /* FileTreeView.swift */; }; + F6071829304152C6E4F50618 /* FileViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6071829304152C6E4F50617 /* FileViewerView.swift */; }; + 07182930415263D7F5061729 /* FileSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07182930415263D7F5061728 /* FileSearchView.swift */; }; 6E32ED1BF961DFEFCA12EF52 /* DatabaseBootstrap.sql in Resources */ = {isa = PBXBuildFile; fileRef = 2856F8D2F2D630DD985B870A /* DatabaseBootstrap.sql */; }; 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */; }; 889573D8A5ED468D3BD12894 /* PRsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4D463D21266B62B422D11 /* PRsTabView.swift */; }; @@ -44,7 +55,18 @@ 31EC445F22FD38F90C16343E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 483C5F1818BAE74B19B84617 /* RemoteModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteModels.swift; path = ADE/Models/RemoteModels.swift; sourceTree = ""; }; 4A9E6135ED52117E41DE95F7 /* ADEDesignSystem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEDesignSystem.swift; path = ADE/Views/Components/ADEDesignSystem.swift; sourceTree = ""; }; - 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesTabView.swift; path = ADE/Views/FilesTabView.swift; sourceTree = ""; }; + 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesTabView.swift; path = ADE/Views/Files/FilesTabView.swift; sourceTree = ""; }; + 7A1B2C3D4E5F60718293A4B4 /* FileViewerChromeViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerChromeViews.swift; path = ADE/Views/Files/FileViewerChromeViews.swift; sourceTree = ""; }; + 7B2C3D4E5F60718293A4B5C5 /* FileViewerCodeEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerCodeEditorView.swift; path = ADE/Views/Files/FileViewerCodeEditorView.swift; sourceTree = ""; }; + 7C3D4E5F60718293A4B5C6D6 /* FileViewerHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerHelpers.swift; path = ADE/Views/Files/FileViewerHelpers.swift; sourceTree = ""; }; + 7D4E5F60718293A4B5C6D7E7 /* FileViewerRenderingViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerRenderingViews.swift; path = ADE/Views/Files/FileViewerRenderingViews.swift; sourceTree = ""; }; + A1B2C3D4E5F60718A9B0C1D2 /* FileOperationsHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileOperationsHelper.swift; path = ADE/Views/Files/FileOperationsHelper.swift; sourceTree = ""; }; + B2C3D4E5F6071829A0B1C2D3 /* FileTreeViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileTreeViewModel.swift; path = ADE/Views/Files/FileTreeViewModel.swift; sourceTree = ""; }; + C3D4E5F607182930B1C2D3E4 /* FileViewerViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerViewModel.swift; path = ADE/Views/Files/FileViewerViewModel.swift; sourceTree = ""; }; + D4E5F6071829304AC2D3E4F5 /* FileSearchViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileSearchViewModel.swift; path = ADE/Views/Files/FileSearchViewModel.swift; sourceTree = ""; }; + E5F60718293041B5D3E4F506 /* FileTreeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileTreeView.swift; path = ADE/Views/Files/FileTreeView.swift; sourceTree = ""; }; + F6071829304152C6E4F50617 /* FileViewerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileViewerView.swift; path = ADE/Views/Files/FileViewerView.swift; sourceTree = ""; }; + 07182930415263D7F5061728 /* FileSearchView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FileSearchView.swift; path = ADE/Views/Files/FileSearchView.swift; sourceTree = ""; }; 51942BAC0965C7D0CCE6E8B8 /* Database.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Database.swift; path = ADE/Services/Database.swift; sourceTree = ""; }; 5EE4D463D21266B62B422D11 /* PRsTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PRsTabView.swift; path = ADE/Views/PRsTabView.swift; sourceTree = ""; }; 66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = ""; }; @@ -91,7 +113,7 @@ isa = PBXGroup; children = ( 02B9E655310A24835B5CFC3B /* Components */, - 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */, + 1A2B3C4D5E6F708192A3B4C5 /* Files */, 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */, 5EE4D463D21266B62B422D11 /* PRsTabView.swift */, 0C6ECFA9D57E70E57A60E8AB /* WorkTabView.swift */, @@ -99,6 +121,25 @@ name = Views; sourceTree = ""; }; + 1A2B3C4D5E6F708192A3B4C5 /* Files */ = { + isa = PBXGroup; + children = ( + 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */, + A1B2C3D4E5F60718A9B0C1D2 /* FileOperationsHelper.swift */, + B2C3D4E5F6071829A0B1C2D3 /* FileTreeViewModel.swift */, + C3D4E5F607182930B1C2D3E4 /* FileViewerViewModel.swift */, + D4E5F6071829304AC2D3E4F5 /* FileSearchViewModel.swift */, + E5F60718293041B5D3E4F506 /* FileTreeView.swift */, + F6071829304152C6E4F50617 /* FileViewerView.swift */, + 07182930415263D7F5061728 /* FileSearchView.swift */, + 7A1B2C3D4E5F60718293A4B4 /* FileViewerChromeViews.swift */, + 7B2C3D4E5F60718293A4B5C5 /* FileViewerCodeEditorView.swift */, + 7C3D4E5F60718293A4B5C6D6 /* FileViewerHelpers.swift */, + 7D4E5F60718293A4B5C6D7E7 /* FileViewerRenderingViews.swift */, + ); + name = Files; + sourceTree = ""; + }; 10899CEEC539910EA438B7D9 /* Services */ = { isa = PBXGroup; children = ( @@ -299,6 +340,17 @@ 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */, 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */, 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */, + 7A1B2C3D4E5F60718293A4B5 /* FileViewerChromeViews.swift in Sources */, + 7B2C3D4E5F60718293A4B5C6 /* FileViewerCodeEditorView.swift in Sources */, + 7C3D4E5F60718293A4B5C6D7 /* FileViewerHelpers.swift in Sources */, + 7D4E5F60718293A4B5C6D7E8 /* FileViewerRenderingViews.swift in Sources */, + A1B2C3D4E5F60718A9B0C1D3 /* FileOperationsHelper.swift in Sources */, + B2C3D4E5F6071829A0B1C2D4 /* FileTreeViewModel.swift in Sources */, + C3D4E5F607182930B1C2D3E5 /* FileViewerViewModel.swift in Sources */, + D4E5F6071829304AC2D3E4F6 /* FileSearchViewModel.swift in Sources */, + E5F60718293041B5D3E4F507 /* FileTreeView.swift in Sources */, + F6071829304152C6E4F50618 /* FileViewerView.swift in Sources */, + 07182930415263D7F5061729 /* FileSearchView.swift in Sources */, E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */, 889573D8A5ED468D3BD12894 /* PRsTabView.swift in Sources */, 56223CC3AF5A01B710CDC4CF /* WorkTabView.swift in Sources */, diff --git a/apps/ios/ADE/Views/Files/FileOperationsHelper.swift b/apps/ios/ADE/Views/Files/FileOperationsHelper.swift new file mode 100644 index 000000000..93d9ddd44 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileOperationsHelper.swift @@ -0,0 +1,460 @@ +import SwiftUI +import UIKit + +// MARK: - Navigation Types + +enum FilesRoute: Hashable { + case directory(workspaceId: String, parentPath: String) + case editor(workspaceId: String, relativePath: String, focusLine: Int?) +} + +struct FilesSearchKey: Hashable { + let workspaceId: String? + let query: String + let isLive: Bool + var retryToken: Int = 0 +} + +// MARK: - Editor Types + +enum FilesEditorMode: String, CaseIterable, Identifiable { + case preview + case edit + case diff + + var id: String { rawValue } + + var title: String { + switch self { + case .preview: return "Preview" + case .edit: return "Edit" + case .diff: return "Diff" + } + } +} + +enum FilesDiffMode: String, CaseIterable, Identifiable { + case unstaged + case staged + + var id: String { rawValue } + + var title: String { + switch self { + case .unstaged: return "Working tree" + case .staged: return "Staged" + } + } +} + +// MARK: - Prompt Types + +enum FilesPromptKind { + case createFile + case createFolder + case rename +} + +struct FilesPathPrompt: Identifiable { + let id = UUID() + let kind: FilesPromptKind + let basePath: String + let node: FileTreeNode? + + var title: String { + switch kind { + case .createFile: return "New file" + case .createFolder: return "New folder" + case .rename: return "Rename" + } + } + + var message: String { + switch kind { + case .createFile: + return basePath.isEmpty ? "Create a file at the workspace root." : "Create a file in \(basePath)." + case .createFolder: + return basePath.isEmpty ? "Create a folder at the workspace root." : "Create a folder in \(basePath)." + case .rename: + return "Rename \(node?.name ?? "this item")." + } + } + + var placeholder: String { + switch kind { + case .createFile: return "example.swift" + case .createFolder: return "NewFolder" + case .rename: return node?.name ?? "Name" + } + } + + var confirmLabel: String { + switch kind { + case .createFile: return "Create" + case .createFolder: return "Create" + case .rename: return "Rename" + } + } + + var initialValue: String { + node?.name ?? "" + } +} + +// MARK: - Destructive Confirmation + +enum FilesDestructiveKind { + case delete(node: FileTreeNode) + case discard(path: String) + case discardUnsaved +} + +struct FilesDestructiveConfirmation: Identifiable { + let id = UUID() + let kind: FilesDestructiveKind + + var title: String { + switch kind { + case .delete(let node): + return "Delete \(node.name)?" + case .discard(let path): + return "Discard changes for \(lastPathComponent(path))?" + case .discardUnsaved: + return "Discard unsaved changes?" + } + } + + var message: String { + switch kind { + case .delete: + return "This permanently removes the item from the host workspace." + case .discard: + return "This permanently loses your local edits." + case .discardUnsaved: + return "Your unsaved edits on iPhone will be lost." + } + } + + var confirmLabel: String { + switch kind { + case .delete: return "Delete" + case .discard, .discardUnsaved: return "Discard" + } + } +} + +// MARK: - State Types + +struct FilesGitState { + var staged: Set = [] + var unstaged: Set = [] + + static let empty = FilesGitState() + + func isStaged(_ path: String) -> Bool { staged.contains(path) } + func isUnstaged(_ path: String) -> Bool { unstaged.contains(path) } + func hasChanges(_ path: String) -> Bool { isStaged(path) || isUnstaged(path) } +} + +struct FilesFileMetadata { + let sizeText: String + let languageLabel: String + let lastCommitTitle: String? + let lastCommitDateText: String? +} + +struct FilesTreeRowItem: Identifiable, Equatable { + enum Kind: Equatable { + case node(FileTreeNode) + case loading(String) + } + + let kind: Kind + let depth: Int + + var id: String { + switch kind { + case .node(let node): + return node.path + case .loading(let path): + return "loading::\(path)" + } + } + + var node: FileTreeNode? { + if case .node(let node) = kind { + return node + } + return nil + } +} + +struct FilesBreadcrumbItem: Equatable { + let label: String + let path: String + let isDirectory: Bool +} + +// MARK: - Editor Navigation + +enum EditorNavigationTarget { + case dismiss + case directory(String) +} + +// MARK: - Reload Key + +struct DirectoryReloadKey: Hashable { + let workspaceId: String + let parentPath: String + let includeHidden: Bool + let live: Bool + let revision: Int +} + +// MARK: - Path Helpers + +func joinedPath(base: String, name: String) -> String { + let cleanedBase = base.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let cleanedName = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !cleanedBase.isEmpty else { return cleanedName } + guard !cleanedName.isEmpty else { return cleanedBase } + return "\(cleanedBase)/\(cleanedName)" +} + +func parentDirectory(of path: String) -> String { + let components = pathComponents(path) + guard components.count > 1 else { return "" } + return components.dropLast().joined(separator: "/") +} + +func pathComponents(_ path: String) -> [String] { + path.split(separator: "/").map(String.init) +} + +func lastPathComponent(_ path: String) -> String { + pathComponents(path).last ?? path +} + +func fileTint(for name: String) -> Color { + let icon = fileIcon(for: name) + switch icon { + case "chevron.left.forwardslash.chevron.right": return .blue + case "doc.badge.gearshape": return .orange + case "doc.text": return .yellow + case "photo": return .pink + case "doc.zipper": return .red + default: return ADEColor.textSecondary + } +} + +func changeStatusTint(_ changeStatus: String) -> Color { + switch changeStatus.uppercased() { + case "A": return ADEColor.success + case "D": return ADEColor.danger + case "M": return ADEColor.warning + default: return ADEColor.textSecondary + } +} + +func changeStatusDescription(_ changeStatus: String) -> String { + switch changeStatus.uppercased() { + case "A": return "Added" + case "D": return "Deleted" + case "M": return "Modified" + default: return changeStatus.uppercased() + } +} + +func filesNameValidationError( + for proposedName: String, + existingNodes: [FileTreeNode], + excluding excludedPath: String? = nil +) -> String? { + let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Name cannot be empty." + } + guard trimmed != "." && trimmed != ".." else { + return "That name is reserved." + } + guard !trimmed.contains("/") && !trimmed.contains("\\") else { + return "Use a single file or folder name here." + } + guard !trimmed.contains("\u{0}") else { + return "Use a single file or folder name here." + } + guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { + return "Use a single file or folder name here." + } + + if let conflict = existingNodes.first(where: { + $0.name.caseInsensitiveCompare(trimmed) == .orderedSame && $0.path != excludedPath + }) { + return "\(conflict.name) already exists in this folder." + } + + return nil +} + +func isBinaryFilePath(_ path: String) -> Bool { + let lowercased = path.lowercased() + let ext = (lowercased as NSString).pathExtension + return [ + "bin", "exe", "dll", "so", "dylib", "o", "a", "class", + "jar", "war", "ear", "wasm", "pyc", + ].contains(ext) +} + +func visibleFilesTreeRows( + nodes: [FileTreeNode], + expandedPaths: Set, + loadingPaths: Set, + childNodesByPath: [String: [FileTreeNode]], + showHidden: Bool, + depth: Int = 0 +) -> [FilesTreeRowItem] { + nodes.flatMap { node -> [FilesTreeRowItem] in + if !showHidden && node.name.hasPrefix(".") { + return [] + } + + var rows: [FilesTreeRowItem] = [.init(kind: .node(node), depth: depth)] + guard node.type == "directory", expandedPaths.contains(node.path) else { + return rows + } + + if loadingPaths.contains(node.path) { + rows.append(.init(kind: .loading(node.path), depth: depth + 1)) + return rows + } + + if let children = childNodesByPath[node.path] { + rows.append(contentsOf: visibleFilesTreeRows( + nodes: children, + expandedPaths: expandedPaths, + loadingPaths: loadingPaths, + childNodesByPath: childNodesByPath, + showHidden: showHidden, + depth: depth + 1 + )) + } + + return rows + } +} + +func filesBreadcrumbItems(relativePath: String, includeCurrentFile: Bool) -> [FilesBreadcrumbItem] { + let components = pathComponents(relativePath) + guard !components.isEmpty else { return [] } + + return components.indices.map { index in + let path = components[0...index].joined(separator: "/") + let isLast = index == components.count - 1 + return FilesBreadcrumbItem( + label: components[index], + path: path, + isDirectory: includeCurrentFile ? !isLast : true + ) + } +} + +private let sharedISOFormatter = ISO8601DateFormatter() +private let sharedRelativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + return f +}() + +func relativeDateDescription(from isoTimestamp: String?) -> String? { + guard let isoTimestamp, let date = sharedISOFormatter.date(from: isoTimestamp) else { return nil } + return sharedRelativeFormatter.localizedString(for: date, relativeTo: Date()) +} + +func filesRouteForDirectory(_ parentPath: String, workspace: FilesWorkspace) -> [FilesRoute] { + let components = pathComponents(parentPath) + guard !components.isEmpty else { return [] } + return components.indices.map { index in + .directory(workspaceId: workspace.id, parentPath: components[0...index].joined(separator: "/")) + } +} + +func filesRouteForFile(_ relativePath: String, workspace: FilesWorkspace, focusLine: Int?) -> [FilesRoute] { + var routes = filesRouteForDirectory(parentDirectory(of: relativePath), workspace: workspace) + routes.append(.editor(workspaceId: workspace.id, relativePath: relativePath, focusLine: focusLine)) + return routes +} + +@MainActor +func filesStatusNotice( + filesStatus: SyncDomainStatus, + workspaces: [FilesWorkspace], + needsRepairing: Bool, + syncService: SyncService, + reload: @escaping () async -> Void +) -> ADENoticeCard? { + switch filesStatus.phase { + case .disconnected: + return ADENoticeCard( + title: workspaces.isEmpty ? "Host disconnected" : "Showing cached workspaces", + message: workspaces.isEmpty + ? (syncService.activeHostProfile == nil + ? "Pair with a host to hydrate the workspace list before browsing files." + : "Reconnect to hydrate the workspace list before browsing files.") + : (needsRepairing + ? "Workspace names are cached locally, but the previous host trust was cleared. Pair again before trusting file state or write access." + : "Workspace information is cached locally. Reconnect before editing, creating, or refreshing files."), + icon: "icloud.slash", + tint: ADEColor.warning, + actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", + action: { + if syncService.activeHostProfile == nil { + syncService.settingsPresented = true + } else { + Task { + await syncService.reconnectIfPossible() + await reload() + } + } + } + ) + case .hydrating: + return ADENoticeCard( + title: "Hydrating workspaces", + message: "Files uses the lane graph for workspace roots. Waiting for the latest lane hydration from the host.", + icon: "arrow.trianglehead.2.clockwise.rotate.90", + tint: ADEColor.accent, + actionTitle: nil, + action: nil + ) + case .syncingInitialData: + return ADENoticeCard( + title: "Syncing initial data", + message: "Waiting for the host to finish syncing project and lane metadata before Files hydrates.", + icon: "arrow.trianglehead.2.clockwise.rotate.90", + tint: ADEColor.warning, + actionTitle: nil, + action: nil + ) + case .failed: + return ADENoticeCard( + title: "Workspace hydration failed", + message: filesStatus.lastError ?? "The lane graph did not hydrate, so Files cannot trust its workspace model yet.", + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload() } } + ) + case .ready: + return nil + } +} + +// MARK: - View Extensions + +extension View { + func filesListRow() -> some View { + listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } +} diff --git a/apps/ios/ADE/Views/Files/FileSearchView.swift b/apps/ios/ADE/Views/Files/FileSearchView.swift new file mode 100644 index 000000000..dba5b8268 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileSearchView.swift @@ -0,0 +1,364 @@ +import Observation +import SwiftUI + +enum FilesSearchMode: String, CaseIterable, Identifiable { + case filenames + case contents + + var id: String { rawValue } + + var title: String { + switch self { + case .filenames: return "Names" + case .contents: return "Content" + } + } +} + +struct FilesWorkspaceCompactBar: View { + let workspaces: [FilesWorkspace] + @Binding var selectedWorkspaceId: String + let selectedWorkspace: FilesWorkspace + + var body: some View { + Menu { + Picker("Workspace", selection: $selectedWorkspaceId) { + ForEach(workspaces) { workspace in + Text(workspace.name).tag(workspace.id) + } + } + } label: { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(selectedWorkspace.name) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(selectedWorkspace.rootPath) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.up.chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + + ADEGlassGroup(spacing: 8) { + ADEStatusPill(text: selectedWorkspace.kind.uppercased(), tint: ADEColor.accent) + if selectedWorkspace.isReadOnlyByDefault { + ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(ADEColor.surfaceBackground.opacity(0.65), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 18)) + } + .accessibilityLabel("\(selectedWorkspace.name). \(selectedWorkspace.rootPath). Switch workspace.") + } +} + +struct FilesSearchSheetView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var searchViewModel: FileSearchViewModel + + let workspace: FilesWorkspace + let canUseLiveFileActions: Bool + let needsRepairing: Bool + let openFile: (String, Int?) -> Void + + @State private var mode: FilesSearchMode = .filenames + + private var activeQueryBinding: Binding { + switch mode { + case .filenames: + return $searchViewModel.quickOpenQuery + case .contents: + return $searchViewModel.textSearchQuery + } + } + + private var activeResultsCount: Int { + switch mode { + case .filenames: + return searchViewModel.quickOpenResults.count + case .contents: + return searchViewModel.textSearchResults.count + } + } + + private var emptyMessage: String { + switch mode { + case .filenames: + return searchViewModel.quickOpenEmptyMessage(canUseLiveFileActions: canUseLiveFileActions, needsRepairing: needsRepairing) + case .contents: + return searchViewModel.textSearchEmptyMessage(canUseLiveFileActions: canUseLiveFileActions, needsRepairing: needsRepairing) + } + } + + private var searchErrorMessage: String? { + searchViewModel.searchErrorMessage + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + searchScopeCard + modePicker + searchQueryCard + if let searchErrorMessage { + ADENoticeCard( + title: "Search failed", + message: searchErrorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Try again", + action: { + searchViewModel.searchErrorMessage = nil + searchViewModel.retryToken += 1 + } + ) + } + searchResults + } + .padding(16) + } + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .background(ADEColor.pageBackground) + } + } + + @ViewBuilder + private var searchScopeCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 32, height: 32) + .background(ADEColor.accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 4) { + Text(workspace.name) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + Text(workspace.rootPath) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + } + + HStack(spacing: 8) { + ADEStatusPill(text: workspace.kind.uppercased(), tint: ADEColor.accent) + if workspace.isReadOnlyByDefault { + ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) + } else if !canUseLiveFileActions { + ADEStatusPill(text: "OFFLINE", tint: ADEColor.warning) + } + } + + Text(canUseLiveFileActions ? "Search is live against the current workspace." : offlineSearchMessage) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .adeGlassCard(cornerRadius: 18) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(workspace.name). \(workspace.rootPath).") + } + + private var offlineSearchMessage: String { + needsRepairing + ? "Pair again before searching files or contents." + : "Reconnect the host before searching files or contents." + } + + private var modePicker: some View { + Picker("Search mode", selection: $mode) { + ForEach(FilesSearchMode.allCases) { item in + Text(item.title).tag(item) + } + } + .pickerStyle(.segmented) + .accessibilityLabel("Search mode") + } + + private var searchQueryCard: some View { + VStack(alignment: .leading, spacing: 10) { + Text(mode == .filenames ? "Search filenames" : "Search contents") + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + TextField(mode == .filenames ? "Search files" : "Search text", text: activeQueryBinding) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(!canUseLiveFileActions) + .adeInsetField() + Text(emptyMessage) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .adeGlassCard(cornerRadius: 18) + } + + @ViewBuilder + private var searchResults: some View { + if !canUseLiveFileActions { + ADEEmptyStateView( + symbol: "icloud.slash", + title: "Search unavailable", + message: offlineSearchMessage + ) + } else if activeResultsCount == 0 { + ADEEmptyStateView( + symbol: mode == .filenames ? "doc.text.magnifyingglass" : "text.magnifyingglass", + title: mode == .filenames ? "No files found" : "No matches found", + message: emptyMessage + ) + } else { + LazyVStack(spacing: 10) { + switch mode { + case .filenames: + ForEach(searchViewModel.quickOpenResults) { item in + Button { + openFile(item.path, nil) + dismiss() + } label: { + FilesFilenameSearchResultRow(item: item) + } + .buttonStyle(.plain) + } + case .contents: + ForEach(searchViewModel.textSearchResults) { item in + Button { + openFile(item.path, item.line) + dismiss() + } label: { + FilesContentSearchResultRow(result: item) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +struct FilesFilenameSearchResultRow: View { + let item: FilesQuickOpenItem + + var body: some View { + HStack(spacing: 12) { + Image(systemName: fileIcon(for: item.path)) + .font(.headline) + .foregroundStyle(fileTint(for: item.path)) + .frame(width: 22) + + VStack(alignment: .leading, spacing: 4) { + Text(lastPathComponent(item.path)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(item.path) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .adeListCard(cornerRadius: 16) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(lastPathComponent(item.path)), \(item.path)") + } +} + +struct FilesContentSearchResultRow: View { + let result: FilesSearchTextMatch + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(systemName: fileIcon(for: result.path)) + .font(.headline) + .foregroundStyle(fileTint(for: result.path)) + .frame(width: 22) + + VStack(alignment: .leading, spacing: 4) { + Text(lastPathComponent(result.path)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(result.path) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + + ADEStatusPill(text: "L\(result.line)", tint: ADEColor.accent) + } + + Text(result.preview) + .font(.caption) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + } + .adeListCard(cornerRadius: 16) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(lastPathComponent(result.path)), line \(result.line)") + } +} + +#Preview("Workspace bar") { + VStack { + FilesWorkspaceCompactBar( + workspaces: [ + FilesWorkspace(id: "workspace-1", kind: "lane", laneId: "lane-1", name: "Mobile Files", rootPath: "/Projects/Mobile Files", isReadOnlyByDefault: false), + FilesWorkspace(id: "workspace-2", kind: "lane", laneId: "lane-2", name: "Read Only", rootPath: "/Projects/Read Only", isReadOnlyByDefault: true), + ], + selectedWorkspaceId: .constant("workspace-1"), + selectedWorkspace: FilesWorkspace(id: "workspace-1", kind: "lane", laneId: "lane-1", name: "Mobile Files", rootPath: "/Projects/Mobile Files", isReadOnlyByDefault: false) + ) + .padding() + Spacer() + } + .background(ADEColor.pageBackground) +} + +#Preview("Search results") { + VStack(spacing: 12) { + FilesFilenameSearchResultRow( + item: FilesQuickOpenItem(path: "Sources/App/FilesTabView.swift", score: 99) + ) + FilesContentSearchResultRow( + result: FilesSearchTextMatch(path: "Sources/App/FilesTabView.swift", line: 42, column: 3, preview: "let showHidden = UserDefaults.standard.bool(forKey: \"ade.files.showHidden\")") + ) + } + .padding() + .background(ADEColor.pageBackground) +} diff --git a/apps/ios/ADE/Views/Files/FileSearchViewModel.swift b/apps/ios/ADE/Views/Files/FileSearchViewModel.swift new file mode 100644 index 000000000..e239c29f7 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileSearchViewModel.swift @@ -0,0 +1,124 @@ +import SwiftUI + +@Observable +class FileSearchViewModel { + var quickOpenQuery = "" + var quickOpenResults: [FilesQuickOpenItem] = [] + var textSearchQuery = "" + var textSearchResults: [FilesSearchTextMatch] = [] + var searchErrorMessage: String? + var retryToken = 0 + private var quickOpenRequestToken = 0 + private var textSearchRequestToken = 0 + + @MainActor + func runQuickOpenSearch(syncService: SyncService, workspaceId: String?, canUseLiveFileActions: Bool) async { + guard canUseLiveFileActions else { + quickOpenResults = [] + return + } + guard let workspaceId else { + quickOpenResults = [] + return + } + let query = quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + quickOpenResults = [] + return + } + + quickOpenRequestToken += 1 + let requestToken = quickOpenRequestToken + do { + try await Task.sleep(nanoseconds: 250_000_000) + } catch { + return + } + guard + !Task.isCancelled, + requestToken == quickOpenRequestToken, + query == quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines) + else { return } + + do { + let results = try await syncService.quickOpen(workspaceId: workspaceId, query: query) + guard !Task.isCancelled, requestToken == quickOpenRequestToken else { return } + quickOpenResults = results + searchErrorMessage = nil + } catch { + guard !Task.isCancelled, requestToken == quickOpenRequestToken else { return } + searchErrorMessage = error.localizedDescription + quickOpenResults = [] + } + } + + @MainActor + func runTextSearch(syncService: SyncService, workspaceId: String?, canUseLiveFileActions: Bool) async { + guard canUseLiveFileActions else { + textSearchResults = [] + return + } + guard let workspaceId else { + textSearchResults = [] + return + } + let query = textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + textSearchResults = [] + return + } + + textSearchRequestToken += 1 + let requestToken = textSearchRequestToken + do { + try await Task.sleep(nanoseconds: 250_000_000) + } catch { + return + } + guard + !Task.isCancelled, + requestToken == textSearchRequestToken, + query == textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + else { return } + + do { + let results = try await syncService.searchText(workspaceId: workspaceId, query: query) + guard !Task.isCancelled, requestToken == textSearchRequestToken else { return } + textSearchResults = results + searchErrorMessage = nil + } catch { + guard !Task.isCancelled, requestToken == textSearchRequestToken else { return } + searchErrorMessage = error.localizedDescription + textSearchResults = [] + } + } + + func quickOpenEmptyMessage(canUseLiveFileActions: Bool, needsRepairing: Bool) -> String { + if !canUseLiveFileActions { + return needsRepairing + ? "Pair again before searching or opening files." + : "Quick open needs a live host connection." + } + if quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Type a filename or path to fuzzy-search the workspace." + } + return "No matching files found." + } + + func textSearchEmptyMessage(canUseLiveFileActions: Bool, needsRepairing: Bool) -> String { + if !canUseLiveFileActions { + return needsRepairing + ? "Pair again before searching workspace contents." + : "Workspace search needs a live host connection." + } + if textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Search across the current workspace and preview matching lines." + } + return "No matches found." + } + + func clear() { + quickOpenResults = [] + textSearchResults = [] + } +} diff --git a/apps/ios/ADE/Views/Files/FileTreeView.swift b/apps/ios/ADE/Views/Files/FileTreeView.swift new file mode 100644 index 000000000..98c1bc185 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileTreeView.swift @@ -0,0 +1,590 @@ +import SwiftUI +import UIKit + +// MARK: - Directory Screen (Drill-down) + +struct FilesDirectoryScreen: View { + @EnvironmentObject private var syncService: SyncService + + let workspace: FilesWorkspace + let parentPath: String + @Binding var showHidden: Bool + let isLive: Bool + let needsRepairing: Bool + let openDirectory: (String) -> Void + let openFile: (String, Int?) -> Void + let transitionNamespace: Namespace.ID? + let selectedFilePath: String? + + var body: some View { + List { + FilesBreadcrumbBar( + relativePath: parentPath, + includeCurrentFile: false, + onSelectDirectory: { path in + openDirectory(path) + } + ) + .filesListRow() + + FilesDirectoryContentsView( + workspace: workspace, + parentPath: parentPath, + showHidden: showHidden, + isLive: isLive, + needsRepairing: needsRepairing, + showDisconnectedNotice: true, + openDirectory: openDirectory, + openFile: openFile, + transitionNamespace: transitionNamespace, + selectedFilePath: selectedFilePath + ) + .environmentObject(syncService) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(parentPath.isEmpty ? "Root" : lastPathComponent(parentPath)) + .refreshable { + try? await syncService.refreshLaneSnapshots() + } + } +} + +// MARK: - Directory Contents + +struct FilesDirectoryContentsView: View { + @EnvironmentObject private var syncService: SyncService + @AppStorage("ade.files.showHidden") private var showHiddenSetting = false + @State private var viewModel = FileTreeViewModel() + + let workspace: FilesWorkspace + let parentPath: String + let showHidden: Bool + let isLive: Bool + let needsRepairing: Bool + let showDisconnectedNotice: Bool + let openDirectory: (String) -> Void + let openFile: (String, Int?) -> Void + let transitionNamespace: Namespace.ID? + let selectedFilePath: String? + + private var canMutateFiles: Bool { + viewModel.canMutateFiles(isLive: isLive, workspace: workspace) + } + + private var canUseGitActions: Bool { + viewModel.canUseGitActions(isLive: isLive, workspace: workspace) + } + + private var visibleRows: [FilesTreeRowItem] { + viewModel.visibleRows(showHidden: showHidden) + } + + var body: some View { + Group { + if showDisconnectedNotice && !isLive { + disconnectedNotice.filesListRow() + } + + if let errorMessage = viewModel.actionErrorMessage { + ADENoticeCard( + title: "File action failed", + message: errorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: nil, + action: nil + ) + .filesListRow() + } + + if let errorMessage = viewModel.errorMessage { + ADENoticeCard( + title: "Directory load failed", + message: errorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await viewModel.reload(syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) } } + ) + .filesListRow() + } + + if viewModel.isLoading && viewModel.nodes.isEmpty { + ForEach(0..<4, id: \.self) { _ in + ADECardSkeleton(rows: 2) + .filesListRow() + } + } else if visibleRows.isEmpty { + ADEEmptyStateView( + symbol: parentPath.isEmpty ? "folder" : "folder.badge.minus", + title: parentPath.isEmpty ? "Workspace is empty" : "Folder is empty", + message: isLive ? "This directory does not have any files to preview on iPhone yet." : "Reconnect to load files from the host." + ) + .filesListRow() + } else { + ForEach(visibleRows) { row in + rowView(for: row) + .filesListRow() + } + } + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + if canMutateFiles { + Button { + viewModel.presentPrompt(.createFile, basePath: parentPath, node: nil) + } label: { + Label("New file", systemImage: "doc.badge.plus") + } + + Button { + viewModel.presentPrompt(.createFolder, basePath: parentPath, node: nil) + } label: { + Label("New folder", systemImage: "folder.badge.plus") + } + } + + Toggle(isOn: $showHiddenSetting) { + Label(showHiddenSetting ? "Hide hidden files" : "Show hidden files", systemImage: showHiddenSetting ? "eye.slash" : "eye") + } + + if !parentPath.isEmpty { + Button { + Task { + try? await syncService.refreshLaneSnapshots() + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Files actions") + } + } + .task(id: DirectoryReloadKey(workspaceId: workspace.id, parentPath: parentPath, includeHidden: showHidden, live: isLive, revision: syncService.localStateRevision)) { + await viewModel.reload(syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + } + .sheet(item: $viewModel.prompt) { prompt in + NavigationStack { + Form { + Section { + Text(prompt.message) + .foregroundStyle(ADEColor.textSecondary) + } + + Section(prompt.title) { + TextField(prompt.placeholder, text: $viewModel.promptValue) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel(prompt.title) + } + + if let actionErrorMessage = viewModel.actionErrorMessage { + Section { + Text(actionErrorMessage) + .foregroundStyle(ADEColor.danger) + } + } + } + .navigationTitle(prompt.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModel.prompt = nil + } + } + + ToolbarItem(placement: .confirmationAction) { + Button(prompt.confirmLabel) { + Task { + await viewModel.confirmPrompt( + syncService: syncService, + workspace: workspace, + parentPath: parentPath, + showHidden: showHidden, + isLive: isLive, + openFile: openFile + ) + } + } + } + } + } + } + .alert(item: $viewModel.destructiveConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.confirmLabel)) { + Task { + await viewModel.confirmDestructiveAction(confirmation, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + } + }, + secondaryButton: .cancel() + ) + } + } + + @ViewBuilder + private func rowView(for row: FilesTreeRowItem) -> some View { + if case .node(let node) = row.kind { + if node.type == "directory" { + FilesTreeDirectoryRow( + node: node, + depth: row.depth, + isExpanded: viewModel.expandedPaths.contains(node.path), + canExpand: node.hasChildren ?? true, + isLoadingChildren: viewModel.loadingPaths.contains(node.path), + transitionNamespace: transitionNamespace, + isSelectedTransitionSource: selectedFilePath == node.path, + openDirectory: { openDirectory(node.path) }, + toggleExpansion: { + Task { + await viewModel.toggleExpansion( + node: node, + syncService: syncService, + workspace: workspace, + showHidden: showHidden, + isLive: isLive + ) + } + } + ) + .contextMenu { contextMenu(for: node) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if canMutateFiles { + Button("Rename") { + viewModel.presentPrompt(.rename, basePath: parentDirectory(of: node.path), node: node) + } + .tint(ADEColor.accent) + + Button("Delete", role: .destructive) { + viewModel.destructiveConfirmation = FilesDestructiveConfirmation(kind: .delete(node: node)) + } + } + } + } else { + Button { + openFile(node.path, nil) + } label: { + FilesTreeFileRow( + node: node, + depth: row.depth, + transitionNamespace: transitionNamespace, + isSelectedTransitionSource: selectedFilePath == node.path + ) + } + .buttonStyle(.plain) + .contextMenu { contextMenu(for: node) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if canMutateFiles { + Button("Rename") { + viewModel.presentPrompt(.rename, basePath: parentDirectory(of: node.path), node: node) + } + .tint(ADEColor.accent) + + Button("Delete", role: .destructive) { + viewModel.destructiveConfirmation = FilesDestructiveConfirmation(kind: .delete(node: node)) + } + } + } + } + } else if case .loading = row.kind { + FilesTreeLoadingRow(depth: row.depth) + } + } + + @ViewBuilder + private func contextMenu(for node: FileTreeNode) -> some View { + Button("Open") { + viewModel.open(node, openDirectory: openDirectory, openFile: openFile) + } + + Button("Copy Path") { + UIPasteboard.general.string = viewModel.absolutePath(for: node.path, workspace: workspace) + } + + Button("Copy Relative Path") { + UIPasteboard.general.string = node.path + } + + if node.type == "directory" && canMutateFiles { + Button("New File") { + viewModel.presentPrompt(.createFile, basePath: node.path, node: nil) + } + + Button("New Folder") { + viewModel.presentPrompt(.createFolder, basePath: node.path, node: nil) + } + } + + Button("Rename") { + viewModel.presentPrompt(.rename, basePath: parentDirectory(of: node.path), node: node) + } + .disabled(!canMutateFiles) + + Button("Delete", role: .destructive) { + viewModel.destructiveConfirmation = FilesDestructiveConfirmation(kind: .delete(node: node)) + } + .disabled(!canMutateFiles) + + if node.type == "file", let laneId = workspace.laneId { + Button("Stage") { + Task { await viewModel.stage(node.path, laneId: laneId, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) } + } + .disabled(!canUseGitActions || !viewModel.gitState.isUnstaged(node.path)) + + Button("Unstage") { + Task { await viewModel.unstage(node.path, laneId: laneId, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) } + } + .disabled(!canUseGitActions || !viewModel.gitState.isStaged(node.path)) + + Button("Discard Changes", role: .destructive) { + viewModel.destructiveConfirmation = FilesDestructiveConfirmation(kind: .discard(path: node.path)) + } + .disabled(!canUseGitActions || !viewModel.gitState.isUnstaged(node.path)) + } + } + + private var disconnectedNotice: ADENoticeCard { + ADENoticeCard( + title: viewModel.nodes.isEmpty ? "Reconnect to load this folder" : "Showing cached directory", + message: needsRepairing + ? "The previous host trust was cleared. Pair again before trusting or editing file state." + : "Edits and refresh are disabled until the host reconnects.", + icon: "icloud.slash", + tint: ADEColor.warning, + actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", + action: { + if syncService.activeHostProfile == nil { + syncService.settingsPresented = true + } else { + Task { + await syncService.reconnectIfPossible() + } + } + } + ) + } +} + +// MARK: - Tree Node Rows + +struct FilesTreeDirectoryRow: View { + let node: FileTreeNode + let depth: Int + let isExpanded: Bool + let canExpand: Bool + let isLoadingChildren: Bool + let transitionNamespace: Namespace.ID? + let isSelectedTransitionSource: Bool + let openDirectory: () -> Void + let toggleExpansion: () -> Void + + var body: some View { + HStack(spacing: 12) { + if canExpand { + Button(action: toggleExpansion) { + Group { + if isLoadingChildren { + ProgressView() + .controlSize(.small) + .frame(width: 20, height: 20) + } else { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 20) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Collapse folder \(node.name)" : "Expand folder \(node.name)") + } else { + Color.clear + .frame(width: 20, height: 20) + } + + Button(action: openDirectory) { + HStack(spacing: 12) { + Image(systemName: "folder.fill") + .font(.headline) + .foregroundStyle(ADEColor.accent) + .frame(width: 22) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-icon-\(node.path)" : nil, in: transitionNamespace) + + VStack(alignment: .leading, spacing: 4) { + Text(node.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-title-\(node.path)" : nil, in: transitionNamespace) + Text(node.path.isEmpty ? "Folder" : node.path) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Open folder \(node.name)") + + Spacer(minLength: 8) + + if let changeStatus = node.changeStatus { + ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.leading, CGFloat(depth) * 18) + .frame(maxWidth: .infinity, alignment: .leading) + .adeListCard(cornerRadius: 16) + } +} + +struct FilesTreeFileRow: View { + let node: FileTreeNode + let depth: Int + let transitionNamespace: Namespace.ID? + let isSelectedTransitionSource: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: fileIcon(for: node.name)) + .font(.headline) + .foregroundStyle(fileTint(for: node.name)) + .frame(width: 22) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-icon-\(node.path)" : nil, in: transitionNamespace) + + VStack(alignment: .leading, spacing: 4) { + Text(node.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-title-\(node.path)" : nil, in: transitionNamespace) + Text(node.path.isEmpty ? "File" : node.path) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + if let size = node.size { + Text(formattedFileSize(size)) + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + } + + if isBinaryFilePath(node.path) { + ADEStatusPill(text: "BIN", tint: ADEColor.warning) + } + + if let changeStatus = node.changeStatus { + ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.leading, CGFloat(depth) * 18) + .frame(maxWidth: .infinity, alignment: .leading) + .adeListCard(cornerRadius: 16) + .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "files-container-\(node.path)" : nil, in: transitionNamespace) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + var parts: [String] = [node.name, "file"] + if let changeStatus = node.changeStatus { + parts.append(changeStatusDescription(changeStatus)) + } + if isBinaryFilePath(node.path) { + parts.append("binary") + } + return parts.joined(separator: ", ") + } +} + +struct FilesTreeLoadingRow: View { + let depth: Int + + var body: some View { + HStack(spacing: 12) { + Color.clear + .frame(width: 20, height: 20) + ProgressView() + .controlSize(.small) + Text("Loading folder contents") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + } + .padding(.leading, CGFloat(depth) * 18) + .frame(maxWidth: .infinity, alignment: .leading) + .adeListCard(cornerRadius: 16) + .accessibilityLabel("Loading folder contents") + } +} + +// MARK: - Breadcrumb Bar + +struct FilesBreadcrumbBar: View { + let relativePath: String + let includeCurrentFile: Bool + let onSelectDirectory: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + Button("root") { + onSelectDirectory("") + } + .buttonStyle(.glass) + .accessibilityLabel("Open root folder") + + ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { _, breadcrumb in + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + + if breadcrumb.isDirectory { + Button(breadcrumb.label) { + onSelectDirectory(breadcrumb.path) + } + .buttonStyle(.glass) + .accessibilityLabel("Open folder \(breadcrumb.label)") + } else { + Text(breadcrumb.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(ADEColor.surfaceBackground, in: Capsule()) + .glassEffect() + .accessibilityLabel("\(breadcrumb.label), current file") + } + } + } + .padding(4) + } + .adeGlassCard(cornerRadius: 18, padding: 12) + } + + private var breadcrumbs: [(label: String, path: String, isDirectory: Bool)] { + filesBreadcrumbItems(relativePath: relativePath, includeCurrentFile: includeCurrentFile) + .map { ($0.label, $0.path, $0.isDirectory) } + } +} diff --git a/apps/ios/ADE/Views/Files/FileTreeViewModel.swift b/apps/ios/ADE/Views/Files/FileTreeViewModel.swift new file mode 100644 index 000000000..8c77d120e --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileTreeViewModel.swift @@ -0,0 +1,272 @@ +import SwiftUI + +@Observable +class FileTreeViewModel { + var nodes: [FileTreeNode] = [] + var gitState = FilesGitState.empty + var errorMessage: String? + var actionErrorMessage: String? + var isLoading = true + var prompt: FilesPathPrompt? + var promptValue = "" + var destructiveConfirmation: FilesDestructiveConfirmation? + var childNodesByPath: [String: [FileTreeNode]] = [:] + var expandedPaths: Set = [] + var loadingPaths: Set = [] + + private var lastIncludeHidden: Bool? + + func canMutateFiles(isLive: Bool, workspace: FilesWorkspace) -> Bool { + isLive && !workspace.isReadOnlyByDefault + } + + func canUseGitActions(isLive: Bool, workspace: FilesWorkspace) -> Bool { + isLive && workspace.laneId != nil + } + + func mutationDisabledReason(isLive: Bool, workspace: FilesWorkspace, needsRepairing: Bool) -> String? { + if workspace.isReadOnlyByDefault { + return "This workspace stays read-only on the host." + } + if !isLive { + return needsRepairing + ? "Pair again before creating, renaming, or deleting files." + : "Reconnect before creating, renaming, or deleting files." + } + return nil + } + + @MainActor + func reload(syncService: SyncService, workspace: FilesWorkspace, parentPath: String, showHidden: Bool, isLive: Bool) async { + guard isLive else { + isLoading = false + return + } + do { + if lastIncludeHidden != showHidden { + childNodesByPath.removeAll() + expandedPaths.removeAll() + loadingPaths.removeAll() + } + lastIncludeHidden = showHidden + isLoading = true + nodes = try await syncService.listTree(workspaceId: workspace.id, parentPath: parentPath, includeIgnored: showHidden) + childNodesByPath[parentPath] = nodes + if let laneId = workspace.laneId { + let changes = try await syncService.fetchLaneChanges(laneId: laneId) + gitState = FilesGitState( + staged: Set(changes.staged.map(\.path)), + unstaged: Set(changes.unstaged.map(\.path)) + ) + } else { + gitState = .empty + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func visibleRows(showHidden: Bool) -> [FilesTreeRowItem] { + visibleFilesTreeRows( + nodes: nodes, + expandedPaths: expandedPaths, + loadingPaths: loadingPaths, + childNodesByPath: childNodesByPath, + showHidden: showHidden + ) + } + + func open(_ node: FileTreeNode, openDirectory: (String) -> Void, openFile: (String, Int?) -> Void) { + if node.type == "directory" { + openDirectory(node.path) + } else { + openFile(node.path, nil) + } + } + + func presentPrompt(_ kind: FilesPromptKind, basePath: String, node: FileTreeNode?) { + prompt = FilesPathPrompt(kind: kind, basePath: basePath, node: node) + promptValue = node?.name ?? "" + actionErrorMessage = nil + } + + @MainActor + func confirmPrompt( + syncService: SyncService, + workspace: FilesWorkspace, + parentPath: String, + showHidden: Bool, + isLive: Bool, + openFile: (String, Int?) -> Void + ) async { + guard let prompt else { return } + let trimmed = promptValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard validatePromptValue(trimmed, prompt: prompt) else { return } + + let targetPath = joinedPath(base: prompt.basePath, name: trimmed) + let refreshPath = prompt.basePath + do { + switch prompt.kind { + case .createFile: + try await syncService.createFile(workspaceId: workspace.id, path: targetPath, content: "") + self.prompt = nil + await reloadAndRefreshSubtree(refreshPath: refreshPath, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + openFile(targetPath, nil) + case .createFolder: + try await syncService.createDirectory(workspaceId: workspace.id, path: targetPath) + self.prompt = nil + await reloadAndRefreshSubtree(refreshPath: refreshPath, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + case .rename: + guard let node = prompt.node else { return } + try await syncService.renamePath(workspaceId: workspace.id, oldPath: node.path, newPath: targetPath) + self.prompt = nil + await reloadAndRefreshSubtree(refreshPath: refreshPath, syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + } + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + } + + @MainActor + func toggleExpansion( + node: FileTreeNode, + syncService: SyncService, + workspace: FilesWorkspace, + showHidden: Bool, + isLive: Bool + ) async { + guard node.type == "directory" else { return } + if expandedPaths.contains(node.path) { + expandedPaths.remove(node.path) + loadingPaths.remove(node.path) + return + } + + expandedPaths.insert(node.path) + guard childNodesByPath[node.path] == nil else { return } + await loadChildren( + syncService: syncService, + workspace: workspace, + directoryPath: node.path, + showHidden: showHidden, + isLive: isLive + ) + } + + @MainActor + func confirmDestructiveAction( + _ confirmation: FilesDestructiveConfirmation, + syncService: SyncService, + workspace: FilesWorkspace, + parentPath: String, + showHidden: Bool, + isLive: Bool + ) async { + switch confirmation.kind { + case .delete(let node): + do { + try await syncService.deletePath(workspaceId: workspace.id, path: node.path) + await reloadAndRefreshSubtree(refreshPath: parentDirectory(of: node.path), syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + case .discard(let path): + guard let laneId = workspace.laneId else { return } + do { + try await syncService.discardFile(laneId: laneId, path: path) + await reloadAndRefreshSubtree(refreshPath: parentDirectory(of: path), syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + case .discardUnsaved: + break + } + } + + @MainActor + private func reloadAndRefreshSubtree( + refreshPath: String, + syncService: SyncService, + workspace: FilesWorkspace, + parentPath: String, + showHidden: Bool, + isLive: Bool + ) async { + await reload(syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + if refreshPath != parentPath { + childNodesByPath.removeValue(forKey: refreshPath) + if expandedPaths.contains(refreshPath) { + await loadChildren(syncService: syncService, workspace: workspace, directoryPath: refreshPath, showHidden: showHidden, isLive: isLive) + } + } + } + + @MainActor + func stage(_ path: String, laneId: String, syncService: SyncService, workspace: FilesWorkspace, parentPath: String, showHidden: Bool, isLive: Bool) async { + do { + try await syncService.stageFile(laneId: laneId, path: path) + await reload(syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + } + + @MainActor + func unstage(_ path: String, laneId: String, syncService: SyncService, workspace: FilesWorkspace, parentPath: String, showHidden: Bool, isLive: Bool) async { + do { + try await syncService.unstageFile(laneId: laneId, path: path) + await reload(syncService: syncService, workspace: workspace, parentPath: parentPath, showHidden: showHidden, isLive: isLive) + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + } + + func absolutePath(for relativePath: String, workspace: FilesWorkspace) -> String { + guard !relativePath.isEmpty else { return workspace.rootPath } + return (workspace.rootPath as NSString).appendingPathComponent(relativePath) + } + + @MainActor + func loadChildren( + syncService: SyncService, + workspace: FilesWorkspace, + directoryPath: String, + showHidden: Bool, + isLive: Bool + ) async { + guard isLive else { return } + loadingPaths.insert(directoryPath) + do { + let loaded = try await syncService.listTree( + workspaceId: workspace.id, + parentPath: directoryPath, + includeIgnored: showHidden + ) + childNodesByPath[directoryPath] = loaded + actionErrorMessage = nil + } catch { + actionErrorMessage = error.localizedDescription + } + loadingPaths.remove(directoryPath) + } + + private func validatePromptValue(_ value: String, prompt: FilesPathPrompt) -> Bool { + if let validationError = filesNameValidationError( + for: value, + existingNodes: nodes, + excluding: prompt.node?.path + ) { + actionErrorMessage = validationError + return false + } + actionErrorMessage = nil + return true + } +} diff --git a/apps/ios/ADE/Views/Files/FileViewerChromeViews.swift b/apps/ios/ADE/Views/Files/FileViewerChromeViews.swift new file mode 100644 index 000000000..8fde1278c --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerChromeViews.swift @@ -0,0 +1,340 @@ +import SwiftUI +import UIKit + +struct FilesViewerHeaderCard: View { + let workspace: FilesWorkspace + let relativePath: String + let blob: SyncFileBlob + let gitState: FilesGitState + let mode: FilesEditorMode + let availableModes: [FilesEditorMode] + let isFilesLive: Bool + let canEdit: Bool + let isDirty: Bool + let onSelectMode: (FilesEditorMode) -> Void + let onSave: () -> Void + let onShowInfo: () -> Void + let stageCurrent: (() -> Void)? + let unstageCurrent: (() -> Void)? + let discardCurrent: (() -> Void)? + + private var displayName: String { + lastPathComponent(relativePath) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: fileIcon(for: relativePath)) + .font(.title3.weight(.semibold)) + .foregroundStyle(fileTint(for: relativePath)) + .frame(width: 42, height: 42) + .background(ADEColor.surfaceBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 12)) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 5) { + Text(displayName) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.85) + Text(parentDirectory(of: relativePath).isEmpty ? "Workspace root" : parentDirectory(of: relativePath)) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .textSelection(.enabled) + .lineLimit(1) + } + + Spacer(minLength: 0) + + Button(action: onShowInfo) { + Image(systemName: "info.circle") + .font(.headline) + } + .accessibilityLabel("File info") + } + + FilesModeControl( + selection: mode, + availableModes: availableModes, + canEdit: canEdit, + onSelectMode: onSelectMode + ) + + ScrollView(.horizontal, showsIndicators: false) { + ADEGlassGroup(spacing: 8) { + ADEStatusPill(text: FilesLanguage.detect(languageId: blob.languageId, filePath: relativePath).displayName.uppercased(), tint: ADEColor.accent) + if blob.isBinary { + ADEStatusPill(text: "BINARY", tint: ADEColor.warning) + } + if workspace.isReadOnlyByDefault { + ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) + } else if !isFilesLive { + ADEStatusPill(text: "DISCONNECTED", tint: ADEColor.warning) + } + if isDirty { + ADEStatusPill(text: "UNSAVED", tint: ADEColor.warning) + } + if let laneId = workspace.laneId, gitState.isUnstaged(relativePath) || gitState.isStaged(relativePath) { + FilesGitActionGroup( + laneId: laneId, + path: relativePath, + gitState: gitState, + stage: stageCurrent, + unstage: unstageCurrent, + discard: discardCurrent + ) + } + } + } + + if isDirty { + Button(action: onSave) { + Label("Save changes", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(ADEColor.accent) + .accessibilityLabel("Save changes") + .disabled(!canEdit) + } + } + .adeGlassCard(cornerRadius: 18) + } +} + +struct FilesModeControl: View { + let selection: FilesEditorMode + let availableModes: [FilesEditorMode] + let canEdit: Bool + let onSelectMode: (FilesEditorMode) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(availableModes) { mode in + let isSelected = selection == mode + if isSelected { + Button { + onSelectMode(mode) + } label: { + VStack(spacing: 2) { + Text(mode.title) + .font(.caption.weight(.semibold)) + if mode == .edit && !canEdit { + Text("Locked") + .font(.caption2) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(ADEColor.accent) + .disabled(mode == .edit && !canEdit) + .accessibilityLabel(mode.title + ((mode == .edit && !canEdit) ? ", locked" : "")) + } else { + Button { + onSelectMode(mode) + } label: { + VStack(spacing: 2) { + Text(mode.title) + .font(.caption.weight(.semibold)) + if mode == .edit && !canEdit { + Text("Locked") + .font(.caption2) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.glass) + .tint(ADEColor.textSecondary) + .disabled(mode == .edit && !canEdit) + .accessibilityLabel(mode.title + ((mode == .edit && !canEdit) ? ", locked" : "")) + } + } + } + } +} + +struct FilesDiffModeControl: View { + let selection: FilesDiffMode + let onSelectMode: (FilesDiffMode) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(FilesDiffMode.allCases) { mode in + let isSelected = selection == mode + if isSelected { + Button { + onSelectMode(mode) + } label: { + Text(mode.title) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(ADEColor.accent) + .accessibilityLabel(mode.title) + } else { + Button { + onSelectMode(mode) + } label: { + Text(mode.title) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glass) + .tint(ADEColor.textSecondary) + .accessibilityLabel(mode.title) + } + } + } + } +} + +struct FilesFindReplaceBar: View { + @Binding var findQuery: String + @Binding var replaceQuery: String + let matchSummary: String + let canReplace: Bool + let onPreviousMatch: () -> Void + let onNextMatch: () -> Void + let onReplaceCurrent: () -> Void + let onReplaceAll: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 6) { + Text("Find") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + TextField("Search in file", text: $findQuery) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.none) + .adeInsetField(cornerRadius: 14, padding: 12) + .accessibilityLabel("Find in file") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Replace") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + TextField("Replacement text", text: $replaceQuery) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.none) + .adeInsetField(cornerRadius: 14, padding: 12) + .accessibilityLabel("Replace text") + } + } + + HStack(spacing: 10) { + Text(matchSummary) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + + Spacer(minLength: 0) + + Button("Previous", action: onPreviousMatch) + .buttonStyle(.glass) + .disabled(findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("Previous match") + + Button("Next", action: onNextMatch) + .buttonStyle(.glass) + .disabled(findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("Next match") + + Button("Replace", action: onReplaceCurrent) + .buttonStyle(.glass) + .disabled(!canReplace || findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("Replace current match") + + Button("All", action: onReplaceAll) + .buttonStyle(.glass) + .disabled(!canReplace || findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel("Replace all matches") + } + } + .adeGlassCard(cornerRadius: 18) + } +} + +struct FilesFileInfoSheetView: View { + @Environment(\.dismiss) private var dismiss + let workspace: FilesWorkspace + let relativePath: String + let blob: SyncFileBlob + let metadata: FilesFileMetadata? + let language: FilesLanguage + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + FilesMetadataRow(label: "Path", value: relativePath.isEmpty ? workspace.rootPath : relativePath) + FilesMetadataRow(label: "Size", value: metadata?.sizeText ?? formattedFileSize(blob.size)) + FilesMetadataRow(label: "Language", value: metadata?.languageLabel ?? language.displayName) + FilesMetadataRow(label: "Last commit", value: metadata?.lastCommitTitle ?? "No commit information available") + FilesMetadataRow(label: "Last change", value: metadata?.lastCommitDateText ?? "No commit information available") + FilesMetadataRow(label: "Workspace", value: workspace.name) + FilesMetadataRow(label: "Root path", value: workspace.rootPath) + } + .padding(16) + } + .navigationTitle("File info") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} + +struct FilesMetadataRow: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(value) + .font(label == "Path" || label == "Root path" ? .caption.monospaced() : .subheadline) + .foregroundStyle(ADEColor.textPrimary) + .textSelection(.enabled) + } + } +} + +struct FilesGitActionGroup: View { + let laneId: String + let path: String + let gitState: FilesGitState + let stage: (() -> Void)? + let unstage: (() -> Void)? + let discard: (() -> Void)? + + var body: some View { + ADEGlassGroup(spacing: 8) { + if gitState.isUnstaged(path), let stage { + Button("Stage", action: stage) + .buttonStyle(.glass) + } + if gitState.isStaged(path), let unstage { + Button("Unstage", action: unstage) + .buttonStyle(.glass) + } + if gitState.isUnstaged(path), let discard { + Button("Discard", role: .destructive, action: discard) + .buttonStyle(.glass) + } + } + .accessibilityLabel("Git file actions") + } +} diff --git a/apps/ios/ADE/Views/Files/FileViewerCodeEditorView.swift b/apps/ios/ADE/Views/Files/FileViewerCodeEditorView.swift new file mode 100644 index 000000000..1032a7988 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerCodeEditorView.swift @@ -0,0 +1,216 @@ +import SwiftUI +import UIKit + +struct FilesCodeEditorView: UIViewRepresentable { + @Binding var text: String + @Binding var selection: NSRange + let isEditable: Bool + let onTextChange: (String) -> Void + let onSelectionChange: (NSRange) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> FilesCodeEditorContainerView { + let container = FilesCodeEditorContainerView() + container.textView.delegate = context.coordinator + container.gutterTextView.delegate = context.coordinator + container.textView.isEditable = isEditable + container.textView.text = text + container.gutterTextView.text = fileViewerLineNumbersText(for: text) + container.textView.selectedRange = selection.clamped(toUTF16Length: (text as NSString).length) + container.configureKeyboardShortcuts(target: context.coordinator) + context.coordinator.container = container + return container + } + + func updateUIView(_ uiView: FilesCodeEditorContainerView, context: Context) { + context.coordinator.parent = self + context.coordinator.container = uiView + + uiView.textView.isEditable = isEditable + uiView.textView.textColor = isEditable ? UIColor(ADEColor.textPrimary) : UIColor(ADEColor.textSecondary) + + if uiView.textView.text != text { + uiView.textView.text = text + uiView.updateLineNumbers() + } + + let safeSelection = selection.clamped(toUTF16Length: (text as NSString).length) + if uiView.textView.selectedRange != safeSelection { + uiView.textView.selectedRange = safeSelection + } + + uiView.updateLineNumbers() + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: FilesCodeEditorView + weak var container: FilesCodeEditorContainerView? + + init(parent: FilesCodeEditorView) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + guard textView === container?.textView else { return } + parent.text = textView.text + parent.onTextChange(textView.text) + container?.updateLineNumbers() + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard textView === container?.textView else { return } + let updatedSelection = textView.selectedRange + if parent.selection != updatedSelection { + parent.selection = updatedSelection + parent.onSelectionChange(updatedSelection) + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === container?.textView else { return } + container?.gutterTextView.contentOffset = scrollView.contentOffset + } + + @objc func insertTab() { insert(snippet: "\t") } + @objc func insertParentheses() { insert(snippet: "()", cursorOffset: 1) } + @objc func insertBraces() { insert(snippet: "{}", cursorOffset: 1) } + @objc func insertBrackets() { insert(snippet: "[]", cursorOffset: 1) } + @objc func insertDoubleQuotes() { insert(snippet: "\"\"", cursorOffset: 1) } + @objc func insertSingleQuotes() { insert(snippet: "''", cursorOffset: 1) } + @objc func insertSemicolon() { insert(snippet: ";") } + @objc func insertColon() { insert(snippet: ":") } + + private func insert(snippet: String, cursorOffset: Int? = nil) { + guard let textView = container?.textView else { return } + let current = textView.selectedRange + let mutable = NSMutableString(string: textView.text) + mutable.replaceCharacters(in: current, with: snippet) + textView.text = mutable as String + + let offset = cursorOffset ?? (snippet as NSString).length + let location = current.location + offset + let updatedSelection = NSRange(location: location, length: 0).clamped(toUTF16Length: mutable.length) + textView.selectedRange = updatedSelection + textViewDidChange(textView) + textViewDidChangeSelection(textView) + } + } +} + +final class FilesCodeEditorContainerView: UIView { + let gutterTextView = UITextView() + let textView = UITextView() + private let stackView = UIStackView() + private let gutterWidth: CGFloat = 44 + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + + configureGutter() + configureTextView() + + stackView.addArrangedSubview(gutterTextView) + stackView.addArrangedSubview(textView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + gutterTextView.widthAnchor.constraint(equalToConstant: gutterWidth), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateLineNumbers() { + gutterTextView.text = fileViewerLineNumbersText(for: textView.text) + } + + func configureKeyboardShortcuts(target: FilesCodeEditorView.Coordinator) { + let buttonGroups = [ + UIBarButtonItemGroup( + barButtonItems: [ + UIBarButtonItem(title: "Tab", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertTab)), + UIBarButtonItem(title: "()", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertParentheses)), + UIBarButtonItem(title: "{}", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertBraces)), + UIBarButtonItem(title: "[]", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertBrackets)), + ], + representativeItem: nil + ), + UIBarButtonItemGroup( + barButtonItems: [ + UIBarButtonItem(title: "\"\"", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertDoubleQuotes)), + UIBarButtonItem(title: "''", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertSingleQuotes)), + UIBarButtonItem(title: ";", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertSemicolon)), + UIBarButtonItem(title: ":", style: .plain, target: target, action: #selector(FilesCodeEditorView.Coordinator.insertColon)), + ], + representativeItem: nil + ), + ] + + textView.inputAssistantItem.leadingBarButtonGroups = buttonGroups + textView.inputAssistantItem.trailingBarButtonGroups = [] + } + + private func configureGutter() { + gutterTextView.backgroundColor = .clear + gutterTextView.textColor = UIColor(ADEColor.textMuted) + gutterTextView.font = .monospacedSystemFont(ofSize: 15, weight: .regular) + gutterTextView.textAlignment = .right + gutterTextView.isEditable = false + gutterTextView.isSelectable = false + gutterTextView.isScrollEnabled = true + gutterTextView.showsVerticalScrollIndicator = false + gutterTextView.showsHorizontalScrollIndicator = false + gutterTextView.textContainerInset = UIEdgeInsets(top: 12, left: 6, bottom: 12, right: 8) + gutterTextView.textContainer.lineFragmentPadding = 0 + gutterTextView.isAccessibilityElement = true + gutterTextView.accessibilityLabel = "Line numbers" + } + + private func configureTextView() { + textView.backgroundColor = .clear + textView.textColor = UIColor(ADEColor.textPrimary) + textView.font = .monospacedSystemFont(ofSize: 15, weight: .regular) + textView.isEditable = true + textView.isSelectable = true + textView.isScrollEnabled = true + textView.showsVerticalScrollIndicator = true + textView.showsHorizontalScrollIndicator = true + textView.textContainerInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 12) + textView.textContainer.lineFragmentPadding = 0 + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + textView.smartQuotesType = .no + textView.smartDashesType = .no + textView.smartInsertDeleteType = .no + textView.spellCheckingType = .no + textView.returnKeyType = .default + textView.allowsEditingTextAttributes = false + textView.isAccessibilityElement = true + textView.accessibilityLabel = "Code editor" + } +} + +private extension NSRange { + func clamped(toUTF16Length length: Int) -> NSRange { + let location = min(max(0, location), length) + let maxLength = max(0, length - location) + return NSRange(location: location, length: min(max(0, self.length), maxLength)) + } +} + diff --git a/apps/ios/ADE/Views/Files/FileViewerHelpers.swift b/apps/ios/ADE/Views/Files/FileViewerHelpers.swift new file mode 100644 index 000000000..1422046fa --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerHelpers.swift @@ -0,0 +1,85 @@ +import Foundation + +struct FilesSearchMatch: Identifiable, Equatable { + let id: Int + let range: NSRange +} + +func fileViewerLineCount(for text: String) -> Int { + max(1, splitPreservingEmptyLines(text).count) +} + +func fileViewerLineNumbersText(for text: String) -> String { + (1...fileViewerLineCount(for: text)).map(String.init).joined(separator: "\n") +} + +func fileViewerFindMatches(in text: String, query: String) -> [NSRange] { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return [] } + + let nsText = text as NSString + let options: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + var matches: [NSRange] = [] + var searchRange = NSRange(location: 0, length: nsText.length) + + while searchRange.location < nsText.length { + let found = nsText.range(of: trimmedQuery, options: options, range: searchRange) + guard found.location != NSNotFound, found.length > 0 else { break } + matches.append(found) + + let nextLocation = found.location + found.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } + + return matches +} + +func fileViewerMatchIndex(containing selection: NSRange, in matches: [NSRange]) -> Int? { + guard selection.location != NSNotFound else { return nil } + return matches.firstIndex { candidate in + NSIntersectionRange(candidate, selection).length > 0 || candidate.location == selection.location + } +} + +func fileViewerReplaceCurrentMatch( + in text: String, + query: String, + replacement: String, + matchIndex: Int +) -> (text: String, selection: NSRange)? { + let matches = fileViewerFindMatches(in: text, query: query) + guard matches.indices.contains(matchIndex) else { return nil } + + let mutable = NSMutableString(string: text) + let range = matches[matchIndex] + mutable.replaceCharacters(in: range, with: replacement) + + let replacementLength = (replacement as NSString).length + let selection = NSRange(location: range.location, length: replacementLength) + return (mutable as String, selection) +} + +func fileViewerReplaceAllMatches( + in text: String, + query: String, + replacement: String +) -> String { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return text } + + let mutable = NSMutableString(string: text) + let options: NSString.CompareOptions = [.caseInsensitive, .diacriticInsensitive] + var searchRange = NSRange(location: 0, length: mutable.length) + + while searchRange.location < mutable.length { + let found = mutable.range(of: trimmedQuery, options: options, range: searchRange) + guard found.location != NSNotFound, found.length > 0 else { break } + mutable.replaceCharacters(in: found, with: replacement) + + let nextLocation = found.location + (replacement as NSString).length + searchRange = NSRange(location: nextLocation, length: mutable.length - nextLocation) + } + + return mutable as String +} + diff --git a/apps/ios/ADE/Views/Files/FileViewerRenderingViews.swift b/apps/ios/ADE/Views/Files/FileViewerRenderingViews.swift new file mode 100644 index 000000000..5b1321fd7 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerRenderingViews.swift @@ -0,0 +1,180 @@ +import SwiftUI +import UIKit + +struct FilesBinaryPreviewView: View { + let relativePath: String + let blob: SyncFileBlob + let imageData: Data? + + var body: some View { + if let imageData, let image = UIImage(data: imageData) { + VStack(alignment: .leading, spacing: 10) { + Text("Preview") + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + ZoomableImageView(image: image) + .frame(minHeight: 280) + } + .adeGlassCard(cornerRadius: 18) + } else { + ADEEmptyStateView( + symbol: "doc.fill", + title: "Binary file", + message: "This file cannot be displayed inline on iPhone yet." + ) + } + } +} + +struct SyntaxHighlightedCodeView: View { + let text: String + let language: FilesLanguage + let focusLine: Int? + + private var lines: [String] { + let split = splitPreservingEmptyLines(text) + return split.isEmpty ? [""] : split + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + HStack(alignment: .top, spacing: 12) { + Text("\(index + 1)") + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + .frame(minWidth: 36, alignment: .trailing) + Text(SyntaxHighlighter.highlightedAttributedString(line.isEmpty ? " " : line, as: language)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background((focusLine == index + 1 ? ADEColor.accent.opacity(0.12) : Color.clear), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .id(index + 1) + } + } + .padding(10) + } + .frame(minHeight: 320) + .adeInsetField(cornerRadius: 16, padding: 0) + .task(id: focusLine) { + guard let focusLine else { return } + withAnimation(.smooth) { + proxy.scrollTo(focusLine, anchor: .center) + } + } + } + } +} + +struct FilesInlineDiffView: View { + let lines: [FilesInlineDiffLine] + let language: FilesLanguage + + var body: some View { + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + HStack(alignment: .top, spacing: 12) { + diffLineNumber(line.originalLineNumber) + diffLineNumber(line.modifiedLineNumber) + Text(SyntaxHighlighter.highlightedAttributedString(line.text.isEmpty ? " " : line.text, as: language)) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(diffBackground(for: line.kind), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + .padding(10) + } + .frame(minHeight: 320) + .adeInsetField(cornerRadius: 16, padding: 0) + } + + private func diffLineNumber(_ lineNumber: Int?) -> some View { + Text(lineNumber.map(String.init) ?? "•") + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + .frame(minWidth: 32, alignment: .trailing) + } + + private func diffBackground(for kind: FilesInlineDiffKind) -> Color { + switch kind { + case .unchanged: return Color.clear + case .added: return ADEColor.success.opacity(0.12) + case .removed: return ADEColor.danger.opacity(0.12) + } + } +} + +struct ZoomableImageView: View { + let image: UIImage + + private let maxScale: CGFloat = 8 + @State private var scale: CGFloat = 1 + @State private var lastScale: CGFloat = 1 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + + var body: some View { + GeometryReader { proxy in + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(scale) + .offset(offset) + .frame(width: proxy.size.width, height: proxy.size.height) + .contentShape(Rectangle()) + .gesture(magnificationGesture.simultaneously(with: dragGesture)) + .onTapGesture(count: 2) { + withAnimation(.spring(duration: 0.3)) { + scale = 1 + lastScale = 1 + offset = .zero + lastOffset = .zero + } + } + } + .adeInsetField(cornerRadius: 16, padding: 0) + } + + private var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { value in + scale = min(maxScale, max(1, lastScale * value)) + } + .onEnded { value in + scale = min(maxScale, max(1, lastScale * value)) + lastScale = scale + if scale == 1 { + withAnimation(.spring(duration: 0.2)) { + offset = .zero + lastOffset = .zero + } + } + } + } + + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + guard scale > 1 else { return } + offset = CGSize(width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height) + } + .onEnded { _ in + lastOffset = offset + } + } +} + diff --git a/apps/ios/ADE/Views/Files/FileViewerView.swift b/apps/ios/ADE/Views/Files/FileViewerView.swift new file mode 100644 index 000000000..db47df038 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerView.swift @@ -0,0 +1,377 @@ +import SwiftUI +import UIKit + +// MARK: - File Editor View + +struct FileEditorView: View { + @EnvironmentObject private var syncService: SyncService + @Environment(\.dismiss) private var dismiss + @State private var viewModel = FileViewerViewModel() + + let workspace: FilesWorkspace + let relativePath: String + let focusLine: Int? + let isFilesLive: Bool + let needsRepairing: Bool + let transitionNamespace: Namespace.ID? + let navigateToDirectory: (String) -> Void + + private var language: FilesLanguage { + viewModel.language(for: relativePath) + } + + private var canEdit: Bool { + viewModel.canEdit(isFilesLive: isFilesLive, workspace: workspace) + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + FilesBreadcrumbBar( + relativePath: relativePath, + includeCurrentFile: true, + onSelectDirectory: { path in + viewModel.attemptNavigation(.directory(path)) { target in + performNavigation(target) + } + } + ) + + if let blob = viewModel.blob { + FilesViewerHeaderCard( + workspace: workspace, + relativePath: relativePath, + blob: blob, + gitState: viewModel.gitState, + mode: viewModel.effectiveMode(workspace: workspace), + availableModes: viewModel.editorModes(workspace: workspace), + isFilesLive: isFilesLive, + canEdit: canEdit, + isDirty: viewModel.isDirty, + onSelectMode: { selectedMode in + if viewModel.editorModes(workspace: workspace).contains(selectedMode) { + viewModel.mode = selectedMode + } + }, + onSave: { + Task { _ = await viewModel.save(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) } + }, + onShowInfo: { + viewModel.showInfoSheet = true + }, + stageCurrent: workspace.laneId == nil ? nil : { + Task { + await viewModel.stageCurrentFile( + laneId: workspace.laneId ?? "", + syncService: syncService, + workspace: workspace, + relativePath: relativePath, + isFilesLive: isFilesLive + ) + } + }, + unstageCurrent: workspace.laneId == nil ? nil : { + Task { + await viewModel.unstageCurrentFile( + laneId: workspace.laneId ?? "", + syncService: syncService, + workspace: workspace, + relativePath: relativePath, + isFilesLive: isFilesLive + ) + } + }, + discardCurrent: workspace.laneId == nil ? nil : { + viewModel.pendingDestructiveConfirmation = FilesDestructiveConfirmation(kind: .discard(path: relativePath)) + } + ) + + FilesFindReplaceBar( + findQuery: Binding( + get: { viewModel.findQuery }, + set: { viewModel.updateSearchQuery($0) } + ), + replaceQuery: Binding( + get: { viewModel.replaceQuery }, + set: { viewModel.replaceQuery = $0 } + ), + matchSummary: viewModel.searchSummaryText, + canReplace: canEdit, + onPreviousMatch: { viewModel.selectPreviousSearchMatch() }, + onNextMatch: { viewModel.selectNextSearchMatch() }, + onReplaceCurrent: { viewModel.replaceCurrentMatch() }, + onReplaceAll: { viewModel.replaceAllMatches() } + ) + + if !isFilesLive { + disconnectedNotice + } + + if let errorMessage = viewModel.errorMessage { + ADENoticeCard( + title: "File load failed", + message: errorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await viewModel.load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) } } + ) + } + + if blob.isBinary { + FilesBinaryPreviewView( + relativePath: relativePath, + blob: blob, + imageData: viewModel.imageData(for: relativePath) + ) + } else { + contentSurface(blob: blob) + } + } + + if viewModel.blob == nil && viewModel.errorMessage == nil { + ADECardSkeleton(rows: 4) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(lastPathComponent(relativePath)) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + viewModel.attemptNavigation(.dismiss) { target in + performNavigation(target) + } + } label: { + Image(systemName: "chevron.left") + } + .accessibilityLabel("Back") + } + + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await viewModel.load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) } } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Refresh file") + .disabled(syncService.activeHostProfile == nil && workspace.laneId == nil) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.showInfoSheet = true + } label: { + Image(systemName: "info.circle") + } + .accessibilityLabel("File info") + .disabled(viewModel.blob == nil) + } + } + .sensoryFeedback(.success, trigger: viewModel.saveTrigger) + .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "files-container-\(relativePath)", in: transitionNamespace) + .task { + await viewModel.load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) + } + .task(id: syncService.localStateRevision) { + await viewModel.load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive, refreshDiff: viewModel.mode == .diff) + } + .task(id: viewModel.mode) { + if viewModel.mode == .diff { + await viewModel.loadDiff(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) + } + } + .task(id: viewModel.diffMode) { + if viewModel.mode == .diff { + await viewModel.loadDiff(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) + } + } + .confirmationDialog( + "Unsaved changes", + isPresented: $viewModel.showUnsavedChangesConfirmation, + titleVisibility: .visible + ) { + Button("Save") { + Task { + let saved = await viewModel.save( + syncService: syncService, + workspace: workspace, + relativePath: relativePath, + isFilesLive: isFilesLive + ) + if saved { + viewModel.performPendingNavigation { target in + performNavigation(target) + } + } + } + } + + Button("Discard Changes", role: .destructive) { + viewModel.performPendingNavigation { target in + performNavigation(target) + } + } + + Button("Cancel", role: .cancel) { + viewModel.cancelPendingNavigation() + } + } message: { + Text("Save, discard, or cancel before leaving this file.") + } + .alert(item: $viewModel.pendingDestructiveConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.confirmLabel)) { + switch confirmation.kind { + case .discard(let path): + guard let laneId = workspace.laneId else { return } + Task { + do { + try await syncService.discardFile(laneId: laneId, path: path) + await viewModel.load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive, refreshDiff: true) + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + case .discardUnsaved: + viewModel.performPendingNavigation { target in + performNavigation(target) + } + case .delete: + break + } + }, + secondaryButton: .cancel() + ) + } + .sheet(isPresented: $viewModel.showInfoSheet) { + if let blob = viewModel.blob { + FilesFileInfoSheetView( + workspace: workspace, + relativePath: relativePath, + blob: blob, + metadata: viewModel.metadata, + language: language + ) + } + } + } + + private func performNavigation(_ target: EditorNavigationTarget) { + switch target { + case .dismiss: + dismiss() + case .directory(let path): + navigateToDirectory(path) + } + } + + @ViewBuilder + private func contentSurface(blob: SyncFileBlob) -> some View { + switch viewModel.effectiveMode(workspace: workspace) { + case .preview: + SyntaxHighlightedCodeView( + text: viewModel.draftText, + language: language, + focusLine: focusLine + ) + case .edit: + VStack(alignment: .leading, spacing: 10) { + if workspace.isReadOnlyByDefault { + ADENoticeCard( + title: "Read only", + message: "This workspace is edit-protected on the host.", + icon: "lock.fill", + tint: ADEColor.warning, + actionTitle: nil, + action: nil + ) + } else if !isFilesLive { + Text("Reconnect to a live host before editing or saving file contents.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + + FilesCodeEditorView( + text: Binding( + get: { viewModel.draftText }, + set: { viewModel.updateDraftText($0) } + ), + selection: Binding( + get: { viewModel.editorSelection }, + set: { viewModel.updateEditorSelection($0) } + ), + isEditable: canEdit, + onTextChange: { newText in + viewModel.updateDraftText(newText) + }, + onSelectionChange: { newSelection in + viewModel.updateEditorSelection(newSelection) + } + ) + .frame(minHeight: 320) + } + case .diff: + VStack(alignment: .leading, spacing: 10) { + if workspace.laneId == nil { + Text("Diff mode requires a lane-backed workspace.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } else { + FilesDiffModeControl( + selection: viewModel.diffMode, + onSelectMode: { viewModel.diffMode = $0 } + ) + + if let diffErrorMessage = viewModel.diffErrorMessage { + ADENoticeCard( + title: "Diff unavailable", + message: diffErrorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await viewModel.loadDiff(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) } } + ) + } else if let diff = viewModel.diff, diff.isBinary == true { + ADEEmptyStateView( + symbol: "doc.badge.gearshape", + title: "Binary diff", + message: "This file changed, but the host reported a binary diff that cannot be rendered inline." + ) + } else if let diff = viewModel.diff { + FilesInlineDiffView( + lines: buildInlineDiffLines(original: diff.original.text, modified: diff.modified.text), + language: FilesLanguage.detect(languageId: diff.language, filePath: relativePath) + ) + } else { + ADECardSkeleton(rows: 4) + } + } + } + } + } + + private var disconnectedNotice: ADENoticeCard { + ADENoticeCard( + title: "Read only while disconnected", + message: needsRepairing + ? "Pair again before trusting file state or saving edits." + : "The last-loaded file content stays visible, but editing and file operations are disabled until the host reconnects.", + icon: "icloud.slash", + tint: ADEColor.warning, + actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", + action: { + if syncService.activeHostProfile == nil { + syncService.settingsPresented = true + } else { + Task { + await syncService.reconnectIfPossible() + } + } + } + ) + } +} diff --git a/apps/ios/ADE/Views/Files/FileViewerViewModel.swift b/apps/ios/ADE/Views/Files/FileViewerViewModel.swift new file mode 100644 index 000000000..ff38a7175 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FileViewerViewModel.swift @@ -0,0 +1,365 @@ +import SwiftUI + +@Observable +class FileViewerViewModel { + var blob: SyncFileBlob? + var draftText = "" + var errorMessage: String? + var metadata: FilesFileMetadata? + var gitState = FilesGitState.empty + var mode: FilesEditorMode = .preview + var diffMode: FilesDiffMode = .unstaged + var diff: FileDiff? + var diffErrorMessage: String? + var saveTrigger = 0 + var pendingDestructiveConfirmation: FilesDestructiveConfirmation? + var pendingNavigationTarget: EditorNavigationTarget? + var showUnsavedChangesConfirmation = false + var showInfoSheet = false + var findQuery = "" + var replaceQuery = "" + var searchMatches: [NSRange] = [] + var selectedSearchMatchIndex: Int? + var editorSelection = NSRange(location: 0, length: 0) + + func language(for relativePath: String) -> FilesLanguage { + FilesLanguage.detect(languageId: blob?.languageId, filePath: relativePath) + } + + func isImagePreviewable(relativePath: String) -> Bool { + let lowercased = relativePath.lowercased() + return ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff"].contains((lowercased as NSString).pathExtension) + } + + func imageData(for relativePath: String) -> Data? { + guard let blob else { return nil } + if blob.encoding.lowercased() == "base64" { + return Data(base64Encoded: blob.content) + } + return Data(blob.content.utf8) + } + + func imageCacheKey(workspace: FilesWorkspace, relativePath: String) -> String { + "files-preview::\(workspace.id)::\(relativePath)" + } + + func canEdit(isFilesLive: Bool, workspace: FilesWorkspace) -> Bool { + isFilesLive && !workspace.isReadOnlyByDefault && blob?.isBinary == false + } + + var isDirty: Bool { + guard let blob, !blob.isBinary else { return false } + return draftText != blob.content + } + + func editorModes(workspace: FilesWorkspace) -> [FilesEditorMode] { + guard blob?.isBinary == false else { return [.preview] } + if workspace.laneId != nil { + return [.preview, .edit, .diff] + } + return [.preview, .edit] + } + + func effectiveMode(workspace: FilesWorkspace) -> FilesEditorMode { + let modes = editorModes(workspace: workspace) + return modes.contains(mode) ? mode : .preview + } + + var searchSummaryText: String { + guard !searchMatches.isEmpty else { + return findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Find text in this file." : "No matches." + } + let current = min((selectedSearchMatchIndex ?? 0) + 1, searchMatches.count) + return "\(current) of \(searchMatches.count) matches" + } + + @MainActor + func load( + syncService: SyncService, + workspace: FilesWorkspace, + relativePath: String, + isFilesLive: Bool, + refreshDiff: Bool = false + ) async { + let cacheKey = imageCacheKey(workspace: workspace, relativePath: relativePath) + + do { + if isImagePreviewable(relativePath: relativePath), + let cachedData = ADEImageCache.shared.cachedData(for: cacheKey) { + let cachedBlob = SyncFileBlob( + path: relativePath, + size: cachedData.count, + mimeType: nil, + encoding: "base64", + isBinary: true, + content: cachedData.base64EncodedString(), + languageId: nil + ) + blob = cachedBlob + await loadGitState(syncService: syncService, workspace: workspace, isFilesLive: isFilesLive) + await loadMetadata(syncService: syncService, workspace: workspace, relativePath: relativePath, from: cachedBlob, isFilesLive: isFilesLive) + if refreshDiff { + await loadDiff(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) + } + errorMessage = nil + return + } + + let wasDirty = isDirty + let loaded = try await syncService.readFile(workspaceId: workspace.id, path: relativePath) + blob = loaded + if loaded.isBinary, isImagePreviewable(relativePath: relativePath), let data = imageData(for: relativePath) { + ADEImageCache.shared.store(data, for: cacheKey) + } + if !loaded.isBinary && (!wasDirty || draftText.isEmpty) { + draftText = loaded.content + } + refreshSearchMatches(preserving: editorSelection) + await loadGitState(syncService: syncService, workspace: workspace, isFilesLive: isFilesLive) + await loadMetadata(syncService: syncService, workspace: workspace, relativePath: relativePath, from: loaded, isFilesLive: isFilesLive) + if refreshDiff { + await loadDiff(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive) + } + errorMessage = nil + } catch { + if shouldClearLoadedFile(for: error) { + blob = nil + metadata = nil + diff = nil + diffErrorMessage = nil + } + errorMessage = error.localizedDescription + } + } + + @MainActor + func loadGitState(syncService: SyncService, workspace: FilesWorkspace, isFilesLive: Bool) async { + guard let laneId = workspace.laneId, isFilesLive else { return } + do { + let changes = try await syncService.fetchLaneChanges(laneId: laneId) + gitState = FilesGitState( + staged: Set(changes.staged.map(\.path)), + unstaged: Set(changes.unstaged.map(\.path)) + ) + } catch { + // Preserve current git state if fetch fails. + } + } + + @MainActor + func loadMetadata( + syncService: SyncService, + workspace: FilesWorkspace, + relativePath: String, + from blob: SyncFileBlob, + isFilesLive: Bool + ) async { + let lang = language(for: relativePath) + var lastCommitTitle: String? + var lastCommitDateText: String? + + if let laneId = workspace.laneId, isFilesLive { + do { + let commits = try await syncService.listRecentCommits(laneId: laneId) + for commit in commits.prefix(25) { + let files = try await syncService.listCommitFiles(laneId: laneId, commitSha: commit.sha) + if files.contains(relativePath) { + lastCommitTitle = commit.subject + lastCommitDateText = relativeDateDescription(from: commit.authoredAt) + break + } + } + } catch { + // Best-effort metadata. + } + } + + metadata = FilesFileMetadata( + sizeText: formattedFileSize(blob.size), + languageLabel: lang.displayName, + lastCommitTitle: lastCommitTitle, + lastCommitDateText: lastCommitDateText + ) + } + + @MainActor + func loadDiff(syncService: SyncService, workspace: FilesWorkspace, relativePath: String, isFilesLive: Bool) async { + guard let laneId = workspace.laneId, isFilesLive else { + diff = nil + diffErrorMessage = nil + return + } + do { + diff = try await syncService.fetchFileDiff(laneId: laneId, path: relativePath, mode: diffMode.rawValue) + diffErrorMessage = nil + } catch { + diffErrorMessage = error.localizedDescription + } + } + + @MainActor + func save(syncService: SyncService, workspace: FilesWorkspace, relativePath: String, isFilesLive: Bool) async -> Bool { + guard canEdit(isFilesLive: isFilesLive, workspace: workspace) else { return false } + do { + try await syncService.writeText(workspaceId: workspace.id, path: relativePath, text: draftText) + saveTrigger += 1 + await load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive, refreshDiff: mode == .diff) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + @MainActor + func stageCurrentFile(laneId: String, syncService: SyncService, workspace: FilesWorkspace, relativePath: String, isFilesLive: Bool) async { + do { + try await syncService.stageFile(laneId: laneId, path: relativePath) + await load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive, refreshDiff: true) + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + func unstageCurrentFile(laneId: String, syncService: SyncService, workspace: FilesWorkspace, relativePath: String, isFilesLive: Bool) async { + do { + try await syncService.unstageFile(laneId: laneId, path: relativePath) + await load(syncService: syncService, workspace: workspace, relativePath: relativePath, isFilesLive: isFilesLive, refreshDiff: true) + } catch { + errorMessage = error.localizedDescription + } + } + + func attemptNavigation(_ target: EditorNavigationTarget, performNavigation: (EditorNavigationTarget) -> Void) { + guard isDirty else { + performNavigation(target) + return + } + pendingNavigationTarget = target + showUnsavedChangesConfirmation = true + } + + func performPendingNavigation(performNavigation: (EditorNavigationTarget) -> Void) { + if let target = pendingNavigationTarget { + performNavigation(target) + pendingNavigationTarget = nil + } + showUnsavedChangesConfirmation = false + } + + func cancelPendingNavigation() { + pendingNavigationTarget = nil + showUnsavedChangesConfirmation = false + } + + func updateDraftText(_ text: String) { + guard draftText != text else { return } + draftText = text + refreshSearchMatches(preserving: editorSelection) + } + + func updateEditorSelection(_ selection: NSRange) { + guard editorSelection != selection else { return } + editorSelection = selection + if let matchIndex = fileViewerMatchIndex(containing: selection, in: searchMatches) { + selectedSearchMatchIndex = matchIndex + } + } + + func updateSearchQuery(_ query: String) { + guard findQuery != query else { return } + findQuery = query + refreshSearchMatches(preserving: editorSelection) + } + + func refreshSearchMatches(preserving selection: NSRange? = nil) { + searchMatches = fileViewerFindMatches(in: draftText, query: findQuery) + guard !searchMatches.isEmpty else { + selectedSearchMatchIndex = nil + return + } + + if let selection, let index = fileViewerMatchIndex(containing: selection, in: searchMatches) { + selectedSearchMatchIndex = index + editorSelection = searchMatches[index] + return + } + + if let selectedSearchMatchIndex, searchMatches.indices.contains(selectedSearchMatchIndex) { + editorSelection = searchMatches[selectedSearchMatchIndex] + return + } + + selectedSearchMatchIndex = 0 + editorSelection = searchMatches[0] + } + + func selectNextSearchMatch() { + guard !searchMatches.isEmpty else { return } + let nextIndex = ((selectedSearchMatchIndex ?? -1) + 1).modulo(searchMatches.count) + selectedSearchMatchIndex = nextIndex + editorSelection = searchMatches[nextIndex] + } + + func selectPreviousSearchMatch() { + guard !searchMatches.isEmpty else { return } + let previousIndex = ((selectedSearchMatchIndex ?? 0) - 1).modulo(searchMatches.count) + selectedSearchMatchIndex = previousIndex + editorSelection = searchMatches[previousIndex] + } + + func replaceCurrentMatch() { + guard !findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let matchIndex = selectedSearchMatchIndex ?? 0 + guard let result = fileViewerReplaceCurrentMatch( + in: draftText, + query: findQuery, + replacement: replaceQuery, + matchIndex: matchIndex + ) else { return } + + draftText = result.text + editorSelection = result.selection + refreshSearchMatches(preserving: result.selection) + if let updatedIndex = fileViewerMatchIndex(containing: result.selection, in: searchMatches) { + selectedSearchMatchIndex = updatedIndex + } + } + + func replaceAllMatches() { + guard !findQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + let updatedText = fileViewerReplaceAllMatches(in: draftText, query: findQuery, replacement: replaceQuery) + guard updatedText != draftText else { return } + draftText = updatedText + editorSelection = NSRange(location: 0, length: 0) + refreshSearchMatches(preserving: editorSelection) + } + + func clearTransientEditorState() { + pendingDestructiveConfirmation = nil + pendingNavigationTarget = nil + showUnsavedChangesConfirmation = false + showInfoSheet = false + } + + private func shouldClearLoadedFile(for error: Error) -> Bool { + let message = error.localizedDescription.lowercased() + return [ + "not found", + "no such file", + "does not exist", + "enoent", + "missing file", + "missing path", + ].contains(where: { message.contains($0) }) + } +} + +private extension Int { + func modulo(_ value: Int) -> Int { + guard value > 0 else { return 0 } + let remainder = self % value + return remainder >= 0 ? remainder : remainder + value + } +} diff --git a/apps/ios/ADE/Views/Files/FilesTabView.swift b/apps/ios/ADE/Views/Files/FilesTabView.swift new file mode 100644 index 000000000..153b96344 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FilesTabView.swift @@ -0,0 +1,309 @@ +import SwiftUI + +struct FilesTabView: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @EnvironmentObject private var syncService: SyncService + @AppStorage("ade.files.showHidden") private var showHidden = false + @Namespace private var fileTransitionNamespace + + @State private var searchViewModel = FileSearchViewModel() + @State private var workspaces: [FilesWorkspace] = [] + @State private var selectedWorkspaceId: String? + @State private var errorMessage: String? + @State private var navigationPath: [FilesRoute] = [] + @State private var refreshFeedbackToken = 0 + @State private var selectedFileTransitionPath: String? + @State private var isSearchPresented = false + + private var filesStatus: SyncDomainStatus { + syncService.status(for: .files) + } + + private var selectedWorkspace: FilesWorkspace? { + workspaces.first(where: { $0.id == selectedWorkspaceId }) ?? workspaces.first + } + + private var canUseLiveFileActions: Bool { + filesStatus.phase == .ready && (syncService.connectionState == .connected || syncService.connectionState == .syncing) + } + + private var needsRepairing: Bool { + syncService.activeHostProfile == nil && !workspaces.isEmpty + } + + private var isLoadingSkeleton: Bool { + filesStatus.phase == .hydrating || filesStatus.phase == .syncingInitialData + } + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + if let notice = filesStatusNotice( + filesStatus: filesStatus, + workspaces: workspaces, + needsRepairing: needsRepairing, + syncService: syncService, + reload: { await reload(refreshRemote: true) } + ) { + notice.filesListRow() + } + + if isLoadingSkeleton { + ForEach(0..<3, id: \.self) { _ in + ADECardSkeleton(rows: 3).filesListRow() + } + } + + if let errorMessage, filesStatus.phase == .ready { + ADENoticeCard( + title: "Files view error", + message: errorMessage, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + .filesListRow() + } + + if filesStatus.phase == .ready && workspaces.isEmpty { + ADEEmptyStateView( + symbol: "folder.badge.questionmark", + title: "No workspaces available", + message: "This host does not currently expose any lane-backed workspaces to browse from iPhone." + ) { + if syncService.activeHostProfile == nil { + Button("Open Settings") { syncService.settingsPresented = true } + .buttonStyle(.glassProminent) + .tint(ADEColor.accent) + } + } + .filesListRow() + } + + if let workspace = selectedWorkspace { + Section { + FilesWorkspaceCompactBar( + workspaces: workspaces, + selectedWorkspaceId: Binding( + get: { selectedWorkspaceId ?? workspace.id }, + set: { selectedWorkspaceId = $0 } + ), + selectedWorkspace: workspace + ) + .filesListRow() + } + + Section("Tree") { + FilesDirectoryContentsView( + workspace: workspace, + parentPath: "", + showHidden: showHidden, + isLive: canUseLiveFileActions, + needsRepairing: needsRepairing, + showDisconnectedNotice: false, + openDirectory: { path in openDirectory(path, in: workspace) }, + openFile: { path, line in openFile(path, in: workspace, focusLine: line) }, + transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, + selectedFilePath: selectedFileTransitionPath + ) + .environmentObject(syncService) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: FilesRoute.self) { route in + destinationView(for: route) + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + isSearchPresented = true + } label: { + Image(systemName: "magnifyingglass") + } + .accessibilityLabel("Search files") + .disabled(selectedWorkspace == nil) + + Button { + Task { await reload(refreshRemote: true) } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Refresh files") + .disabled(syncService.activeHostProfile == nil && workspaces.isEmpty) + + Menu { + Button(showHidden ? "Hide hidden files" : "Show hidden files") { + showHidden.toggle() + } + .accessibilityLabel(showHidden ? "Hide hidden files" : "Show hidden files") + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("More files options") + } + } + .sheet(isPresented: $isSearchPresented) { + if let workspace = selectedWorkspace { + FilesSearchSheetView( + searchViewModel: searchViewModel, + workspace: workspace, + canUseLiveFileActions: canUseLiveFileActions, + needsRepairing: needsRepairing, + openFile: { path, line in + isSearchPresented = false + openFile(path, in: workspace, focusLine: line) + } + ) + .environmentObject(syncService) + } else { + NavigationStack { + ADEEmptyStateView( + symbol: "magnifyingglass", + title: "No workspace selected", + message: "Pick a workspace before searching files." + ) + .padding() + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { isSearchPresented = false } + } + } + } + } + } + .refreshable { await refreshFromPullGesture() } + .sensoryFeedback(.selection, trigger: selectedWorkspaceId) + .sensoryFeedback(.success, trigger: refreshFeedbackToken) + .task { await reload() } + .task(id: syncService.localStateRevision) { await reload() } + .task(id: FilesSearchKey(workspaceId: selectedWorkspaceId, query: searchViewModel.quickOpenQuery, isLive: canUseLiveFileActions, retryToken: searchViewModel.retryToken)) { + await searchViewModel.runQuickOpenSearch(syncService: syncService, workspaceId: selectedWorkspaceId, canUseLiveFileActions: canUseLiveFileActions) + } + .task(id: FilesSearchKey(workspaceId: selectedWorkspaceId, query: searchViewModel.textSearchQuery, isLive: canUseLiveFileActions, retryToken: searchViewModel.retryToken)) { + await searchViewModel.runTextSearch(syncService: syncService, workspaceId: selectedWorkspaceId, canUseLiveFileActions: canUseLiveFileActions) + } + .task(id: syncService.requestedFilesNavigation?.id) { await handleRequestedNavigation() } + .onChange(of: selectedWorkspaceId) { _, _ in + navigationPath = [] + searchViewModel.clear() + searchViewModel.searchErrorMessage = nil + } + } + } + + @ViewBuilder + private func destinationView(for route: FilesRoute) -> some View { + switch route { + case .directory(let workspaceId, let parentPath): + if let workspace = workspaces.first(where: { $0.id == workspaceId }) { + FilesDirectoryScreen( + workspace: workspace, + parentPath: parentPath, + showHidden: $showHidden, + isLive: canUseLiveFileActions, + needsRepairing: needsRepairing, + openDirectory: { path in openDirectory(path, in: workspace) }, + openFile: { path, line in openFile(path, in: workspace, focusLine: line) }, + transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, + selectedFilePath: selectedFileTransitionPath + ) + .environmentObject(syncService) + } else { + ADEEmptyStateView( + symbol: "folder.badge.questionmark", + title: "Workspace unavailable", + message: "The selected workspace is no longer available on this device." + ) + .adeScreenBackground() + .adeNavigationGlass() + } + case .editor(let workspaceId, let relativePath, let focusLine): + if let workspace = workspaces.first(where: { $0.id == workspaceId }) { + FileEditorView( + workspace: workspace, + relativePath: relativePath, + focusLine: focusLine, + isFilesLive: canUseLiveFileActions, + needsRepairing: needsRepairing, + transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, + navigateToDirectory: { path in openDirectory(path, in: workspace) } + ) + .environmentObject(syncService) + } else { + ADEEmptyStateView( + symbol: "doc.badge.questionmark", + title: "File unavailable", + message: "The workspace for this file is no longer available." + ) + .adeScreenBackground() + .adeNavigationGlass() + } + } + } + + // MARK: - Actions + + @MainActor + private func refreshFromPullGesture() async { + await reload(refreshRemote: true) + if errorMessage == nil { + withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { + refreshFeedbackToken += 1 + } + } + } + + @MainActor + private func reload(refreshRemote: Bool = false) async { + do { + if refreshRemote { try? await syncService.refreshLaneSnapshots() } + workspaces = try await syncService.listWorkspaces() + selectedWorkspaceId = selectedWorkspaceId.flatMap { candidate in + workspaces.contains(where: { $0.id == candidate }) ? candidate : nil + } ?? workspaces.first?.id + if !canUseLiveFileActions { searchViewModel.clear() } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func handleRequestedNavigation() async { + guard let request = syncService.requestedFilesNavigation else { return } + if workspaces.isEmpty { await reload() } + guard let workspace = workspaces.first(where: { $0.id == request.workspaceId }) else { + syncService.requestedFilesNavigation = nil + return + } + selectedWorkspaceId = workspace.id + if let relativePath = request.relativePath, !relativePath.isEmpty { + selectedFileTransitionPath = relativePath + openFile(relativePath, in: workspace, focusLine: nil) + } else { + selectedFileTransitionPath = nil + navigationPath = [] + } + syncService.requestedFilesNavigation = nil + } + + private func openDirectory(_ parentPath: String, in workspace: FilesWorkspace) { + selectedWorkspaceId = workspace.id + selectedFileTransitionPath = nil + navigationPath = filesRouteForDirectory(parentPath, workspace: workspace) + } + + private func openFile(_ relativePath: String, in workspace: FilesWorkspace, focusLine: Int?) { + selectedWorkspaceId = workspace.id + selectedFileTransitionPath = relativePath + navigationPath = filesRouteForFile(relativePath, workspace: workspace, focusLine: focusLine) + } +} diff --git a/apps/ios/ADE/Views/FilesTabView.swift b/apps/ios/ADE/Views/FilesTabView.swift deleted file mode 100644 index c39569153..000000000 --- a/apps/ios/ADE/Views/FilesTabView.swift +++ /dev/null @@ -1,2196 +0,0 @@ -import SwiftUI -import UIKit - -private enum FilesRoute: Hashable { - case directory(workspaceId: String, parentPath: String) - case editor(workspaceId: String, relativePath: String, focusLine: Int?) -} - -private struct FilesSearchKey: Hashable { - let workspaceId: String? - let query: String - let isLive: Bool -} - -private enum FilesEditorMode: String, CaseIterable, Identifiable { - case preview - case edit - case diff - - var id: String { rawValue } - - var title: String { - switch self { - case .preview: return "Preview" - case .edit: return "Edit" - case .diff: return "Diff" - } - } -} - -private enum FilesDiffMode: String, CaseIterable, Identifiable { - case unstaged - case staged - - var id: String { rawValue } - - var title: String { - switch self { - case .unstaged: return "Working tree" - case .staged: return "Staged" - } - } -} - -private enum FilesPromptKind { - case createFile - case createFolder - case rename -} - -private struct FilesPathPrompt: Identifiable { - let id = UUID() - let kind: FilesPromptKind - let basePath: String - let node: FileTreeNode? - - var title: String { - switch kind { - case .createFile: - return "New file" - case .createFolder: - return "New folder" - case .rename: - return "Rename" - } - } - - var message: String { - switch kind { - case .createFile: - return basePath.isEmpty ? "Create a file at the workspace root." : "Create a file in \(basePath)." - case .createFolder: - return basePath.isEmpty ? "Create a folder at the workspace root." : "Create a folder in \(basePath)." - case .rename: - return "Rename \(node?.name ?? "this item")." - } - } - - var placeholder: String { - switch kind { - case .createFile: - return "example.swift" - case .createFolder: - return "NewFolder" - case .rename: - return node?.name ?? "Name" - } - } - - var confirmLabel: String { - switch kind { - case .createFile: - return "Create" - case .createFolder: - return "Create" - case .rename: - return "Rename" - } - } - - var initialValue: String { - node?.name ?? "" - } -} - -private enum FilesDestructiveKind { - case delete(node: FileTreeNode) - case discard(path: String) - case discardUnsaved -} - -private struct FilesDestructiveConfirmation: Identifiable { - let id = UUID() - let kind: FilesDestructiveKind - - var title: String { - switch kind { - case .delete(let node): - return "Delete \(node.name)?" - case .discard(let path): - return "Discard changes for \(lastPathComponent(path))?" - case .discardUnsaved: - return "Discard unsaved changes?" - } - } - - var message: String { - switch kind { - case .delete: - return "This permanently removes the item from the host workspace." - case .discard: - return "This permanently loses your local edits." - case .discardUnsaved: - return "Your unsaved edits on iPhone will be lost." - } - } - - var confirmLabel: String { - switch kind { - case .delete: - return "Delete" - case .discard, .discardUnsaved: - return "Discard" - } - } -} - -private struct FilesGitState { - var staged: Set = [] - var unstaged: Set = [] - - static let empty = FilesGitState() - - func isStaged(_ path: String) -> Bool { - staged.contains(path) - } - - func isUnstaged(_ path: String) -> Bool { - unstaged.contains(path) - } - - func hasChanges(_ path: String) -> Bool { - isStaged(path) || isUnstaged(path) - } -} - -private struct FilesFileMetadata { - let sizeText: String - let languageLabel: String - let lastCommitTitle: String? - let lastCommitDateText: String? -} - -struct FilesTabView: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @EnvironmentObject private var syncService: SyncService - @AppStorage("ade.files.showHidden") private var showHidden = false - @Namespace private var fileTransitionNamespace - - @State private var workspaces: [FilesWorkspace] = [] - @State private var selectedWorkspaceId: String? - @State private var quickOpenQuery = "" - @State private var quickOpenResults: [FilesQuickOpenItem] = [] - @State private var textSearchQuery = "" - @State private var textSearchResults: [FilesSearchTextMatch] = [] - @State private var errorMessage: String? - @State private var navigationPath: [FilesRoute] = [] - @State private var refreshFeedbackToken = 0 - @State private var selectedFileTransitionPath: String? - - private var filesStatus: SyncDomainStatus { - syncService.status(for: .files) - } - - private var selectedWorkspace: FilesWorkspace? { - workspaces.first(where: { $0.id == selectedWorkspaceId }) ?? workspaces.first - } - - private var canUseLiveFileActions: Bool { - filesStatus.phase == .ready && (syncService.connectionState == .connected || syncService.connectionState == .syncing) - } - - private var needsRepairing: Bool { - syncService.activeHostProfile == nil && !workspaces.isEmpty - } - - private var isLoadingSkeleton: Bool { - filesStatus.phase == .hydrating || filesStatus.phase == .syncingInitialData - } - - var body: some View { - NavigationStack(path: $navigationPath) { - List { - if let notice = statusNotice { - notice.filesListRow() - } - - if isLoadingSkeleton { - ForEach(0..<3, id: \.self) { _ in - ADECardSkeleton(rows: 3) - .filesListRow() - } - } - - if let errorMessage, filesStatus.phase == .ready { - ADENoticeCard( - title: "Files view error", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload(refreshRemote: true) } } - ) - .filesListRow() - } - - if filesStatus.phase == .ready && workspaces.isEmpty { - ADEEmptyStateView( - symbol: "folder.badge.questionmark", - title: "No workspaces available", - message: "This host does not currently expose any lane-backed workspaces to browse from iPhone." - ) { - if syncService.activeHostProfile == nil { - Button("Open Settings") { - syncService.settingsPresented = true - } - .buttonStyle(.glassProminent) - .tint(ADEColor.accent) - } - } - .filesListRow() - } - - if let workspace = selectedWorkspace { - Section("Workspace") { - FilesWorkspaceHeader( - workspaces: workspaces, - selectedWorkspaceId: Binding( - get: { selectedWorkspaceId ?? workspace.id }, - set: { selectedWorkspaceId = $0 } - ), - selectedWorkspace: workspace, - showHidden: $showHidden - ) - .filesListRow() - } - - Section("Quick open") { - FilesQueryCard( - title: "Quick open", - prompt: "Search files", - query: $quickOpenQuery, - disabled: !canUseLiveFileActions, - emptyMessage: quickOpenEmptyMessage - ) - .filesListRow() - - if canUseLiveFileActions { - ForEach(quickOpenResults) { item in - Button { - openFile(item.path, in: workspace, focusLine: nil) - } label: { - FilesResultRow( - path: item.path, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, - isSelectedTransitionSource: selectedFileTransitionPath == item.path - ) - } - .buttonStyle(.plain) - .filesListRow() - } - } - } - - Section("Text search") { - FilesQueryCard( - title: "Workspace search", - prompt: "Search text", - query: $textSearchQuery, - disabled: !canUseLiveFileActions, - emptyMessage: textSearchEmptyMessage - ) - .filesListRow() - - if canUseLiveFileActions { - ForEach(textSearchResults) { result in - Button { - openFile(result.path, in: workspace, focusLine: result.line) - } label: { - FilesSearchResultRow( - result: result, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, - isSelectedTransitionSource: selectedFileTransitionPath == result.path - ) - } - .buttonStyle(.plain) - .filesListRow() - } - } - } - - Section("Tree") { - FilesDirectoryContentsView( - workspace: workspace, - parentPath: "", - showHidden: showHidden, - isLive: canUseLiveFileActions, - needsRepairing: needsRepairing, - showDisconnectedNotice: false, - openDirectory: { path in - openDirectory(path, in: workspace) - }, - openFile: { path, line in - openFile(path, in: workspace, focusLine: line) - }, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, - selectedFilePath: selectedFileTransitionPath - ) - .environmentObject(syncService) - } - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Files") - .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: FilesRoute.self) { route in - switch route { - case .directory(let workspaceId, let parentPath): - if let workspace = workspaces.first(where: { $0.id == workspaceId }) { - FilesDirectoryScreen( - workspace: workspace, - parentPath: parentPath, - showHidden: $showHidden, - isLive: canUseLiveFileActions, - needsRepairing: needsRepairing, - openDirectory: { path in - openDirectory(path, in: workspace) - }, - openFile: { path, line in - openFile(path, in: workspace, focusLine: line) - }, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, - selectedFilePath: selectedFileTransitionPath - ) - .environmentObject(syncService) - } else { - ADEEmptyStateView( - symbol: "folder.badge.questionmark", - title: "Workspace unavailable", - message: "The selected workspace is no longer available on this device." - ) - .adeScreenBackground() - .adeNavigationGlass() - } - case .editor(let workspaceId, let relativePath, let focusLine): - if let workspace = workspaces.first(where: { $0.id == workspaceId }) { - FileEditorView( - workspace: workspace, - relativePath: relativePath, - focusLine: focusLine, - isFilesLive: canUseLiveFileActions, - needsRepairing: needsRepairing, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? fileTransitionNamespace : nil, - navigateToDirectory: { path in - openDirectory(path, in: workspace) - } - ) - .environmentObject(syncService) - } else { - ADEEmptyStateView( - symbol: "doc.badge.questionmark", - title: "File unavailable", - message: "The workspace for this file is no longer available." - ) - .adeScreenBackground() - .adeNavigationGlass() - } - } - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await reload(refreshRemote: true) } - } label: { - Image(systemName: "arrow.clockwise") - } - .accessibilityLabel("Refresh files") - .disabled(syncService.activeHostProfile == nil && workspaces.isEmpty) - } - } - .refreshable { - await refreshFromPullGesture() - } - .sensoryFeedback(.selection, trigger: selectedWorkspaceId) - .sensoryFeedback(.success, trigger: quickOpenResults.count + textSearchResults.count) - .sensoryFeedback(.success, trigger: refreshFeedbackToken) - .task { - await reload() - } - .task(id: syncService.localStateRevision) { - await reload() - } - .task(id: FilesSearchKey(workspaceId: selectedWorkspaceId, query: quickOpenQuery, isLive: canUseLiveFileActions)) { - await runQuickOpenSearch() - } - .task(id: FilesSearchKey(workspaceId: selectedWorkspaceId, query: textSearchQuery, isLive: canUseLiveFileActions)) { - await runTextSearch() - } - .task(id: syncService.requestedFilesNavigation?.id) { - await handleRequestedNavigation() - } - .onChange(of: selectedWorkspaceId) { _, _ in - navigationPath = [] - quickOpenResults = [] - textSearchResults = [] - } - } - } - - @MainActor - private func refreshFromPullGesture() async { - await reload(refreshRemote: true) - if errorMessage == nil { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { - refreshFeedbackToken += 1 - } - } - } - - @MainActor - private func reload(refreshRemote: Bool = false) async { - do { - if refreshRemote { - try? await syncService.refreshLaneSnapshots() - } - workspaces = try await syncService.listWorkspaces() - selectedWorkspaceId = selectedWorkspaceId.flatMap { candidate in - workspaces.contains(where: { $0.id == candidate }) ? candidate : nil - } ?? workspaces.first?.id - if !canUseLiveFileActions { - quickOpenResults = [] - textSearchResults = [] - } - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func runQuickOpenSearch() async { - guard canUseLiveFileActions else { - quickOpenResults = [] - return - } - guard let workspaceId = selectedWorkspaceId else { - quickOpenResults = [] - return - } - let query = quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { - quickOpenResults = [] - return - } - - try? await Task.sleep(nanoseconds: 250_000_000) - guard query == quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines), workspaceId == selectedWorkspaceId else { return } - - do { - quickOpenResults = try await syncService.quickOpen(workspaceId: workspaceId, query: query) - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - quickOpenResults = [] - } - } - - @MainActor - private func runTextSearch() async { - guard canUseLiveFileActions else { - textSearchResults = [] - return - } - guard let workspaceId = selectedWorkspaceId else { - textSearchResults = [] - return - } - let query = textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - guard !query.isEmpty else { - textSearchResults = [] - return - } - - try? await Task.sleep(nanoseconds: 250_000_000) - guard query == textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines), workspaceId == selectedWorkspaceId else { return } - - do { - textSearchResults = try await syncService.searchText(workspaceId: workspaceId, query: query) - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - textSearchResults = [] - } - } - - @MainActor - private func handleRequestedNavigation() async { - guard let request = syncService.requestedFilesNavigation else { return } - if workspaces.isEmpty { - await reload() - } - guard let workspace = workspaces.first(where: { $0.id == request.workspaceId }) else { - syncService.requestedFilesNavigation = nil - return - } - selectedWorkspaceId = workspace.id - if let relativePath = request.relativePath, !relativePath.isEmpty { - selectedFileTransitionPath = relativePath - openFile(relativePath, in: workspace, focusLine: nil) - } else { - selectedFileTransitionPath = nil - navigationPath = [] - } - syncService.requestedFilesNavigation = nil - } - - private func openDirectory(_ parentPath: String, in workspace: FilesWorkspace) { - selectedWorkspaceId = workspace.id - selectedFileTransitionPath = nil - navigationPath = routesForDirectory(parentPath, workspace: workspace) - } - - private func openFile(_ relativePath: String, in workspace: FilesWorkspace, focusLine: Int?) { - selectedWorkspaceId = workspace.id - selectedFileTransitionPath = relativePath - navigationPath = routesForFile(relativePath, workspace: workspace, focusLine: focusLine) - } - - private func routesForDirectory(_ parentPath: String, workspace: FilesWorkspace) -> [FilesRoute] { - let components = pathComponents(parentPath) - guard !components.isEmpty else { return [] } - return components.indices.map { index in - .directory(workspaceId: workspace.id, parentPath: components[0...index].joined(separator: "/")) - } - } - - private func routesForFile(_ relativePath: String, workspace: FilesWorkspace, focusLine: Int?) -> [FilesRoute] { - var routes = routesForDirectory(parentDirectory(of: relativePath), workspace: workspace) - routes.append(.editor(workspaceId: workspace.id, relativePath: relativePath, focusLine: focusLine)) - return routes - } - - private var quickOpenEmptyMessage: String { - if !canUseLiveFileActions { - return needsRepairing - ? "Pair again before searching or opening files." - : "Quick open needs a live host connection." - } - if quickOpenQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "Type a filename or path to fuzzy-search the workspace." - } - return "No matching files found." - } - - private var textSearchEmptyMessage: String { - if !canUseLiveFileActions { - return needsRepairing - ? "Pair again before searching workspace contents." - : "Workspace search needs a live host connection." - } - if textSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "Search across the current workspace and preview matching lines." - } - return "No matches found." - } - - private var statusNotice: ADENoticeCard? { - switch filesStatus.phase { - case .disconnected: - return ADENoticeCard( - title: workspaces.isEmpty ? "Host disconnected" : "Showing cached workspaces", - message: workspaces.isEmpty - ? (syncService.activeHostProfile == nil - ? "Pair with a host to hydrate the workspace list before browsing files." - : "Reconnect to hydrate the workspace list before browsing files.") - : (needsRepairing - ? "Workspace names are cached locally, but the previous host trust was cleared. Pair again before trusting file state or write access." - : "Workspace information is cached locally. Reconnect before editing, creating, or refreshing files."), - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible() - await reload(refreshRemote: true) - } - } - } - ) - case .hydrating: - return ADENoticeCard( - title: "Hydrating workspaces", - message: "Files uses the lane graph for workspace roots. Waiting for the latest lane hydration from the host.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.accent, - actionTitle: nil, - action: nil - ) - case .syncingInitialData: - return ADENoticeCard( - title: "Syncing initial data", - message: "Waiting for the host to finish syncing project and lane metadata before Files hydrates.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .failed: - return ADENoticeCard( - title: "Workspace hydration failed", - message: filesStatus.lastError ?? "The lane graph did not hydrate, so Files cannot trust its workspace model yet.", - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload(refreshRemote: true) } } - ) - case .ready: - return nil - } - } -} - -private struct FilesDirectoryScreen: View { - @EnvironmentObject private var syncService: SyncService - - let workspace: FilesWorkspace - let parentPath: String - @Binding var showHidden: Bool - let isLive: Bool - let needsRepairing: Bool - let openDirectory: (String) -> Void - let openFile: (String, Int?) -> Void - let transitionNamespace: Namespace.ID? - let selectedFilePath: String? - - var body: some View { - List { - FilesBreadcrumbBar( - relativePath: parentPath, - includeCurrentFile: false, - onSelectDirectory: { path in - openDirectory(path) - } - ) - .filesListRow() - - FilesDirectoryContentsView( - workspace: workspace, - parentPath: parentPath, - showHidden: showHidden, - isLive: isLive, - needsRepairing: needsRepairing, - showDisconnectedNotice: true, - openDirectory: openDirectory, - openFile: openFile, - transitionNamespace: transitionNamespace, - selectedFilePath: selectedFilePath - ) - .environmentObject(syncService) - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle(parentPath.isEmpty ? "Root" : lastPathComponent(parentPath)) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showHidden.toggle() - } label: { - Image(systemName: showHidden ? "eye.slash" : "eye") - } - .accessibilityLabel(showHidden ? "Hide hidden files" : "Show hidden files") - - Button { - Task { - try? await syncService.refreshLaneSnapshots() - } - } label: { - Image(systemName: "arrow.clockwise") - } - .accessibilityLabel("Refresh files for this lane") - } - } - } -} - -private struct FilesDirectoryContentsView: View { - @EnvironmentObject private var syncService: SyncService - - let workspace: FilesWorkspace - let parentPath: String - let showHidden: Bool - let isLive: Bool - let needsRepairing: Bool - let showDisconnectedNotice: Bool - let openDirectory: (String) -> Void - let openFile: (String, Int?) -> Void - let transitionNamespace: Namespace.ID? - let selectedFilePath: String? - - @State private var nodes: [FileTreeNode] = [] - @State private var gitState = FilesGitState.empty - @State private var errorMessage: String? - @State private var actionErrorMessage: String? - @State private var isLoading = true - @State private var prompt: FilesPathPrompt? - @State private var promptValue = "" - @State private var destructiveConfirmation: FilesDestructiveConfirmation? - - private var canMutateFiles: Bool { - isLive && !workspace.isReadOnlyByDefault - } - - private var canUseGitActions: Bool { - isLive && workspace.laneId != nil - } - - var body: some View { - Group { - FilesDirectoryActionRow( - canMutateFiles: canMutateFiles, - mutationDisabledReason: mutationDisabledReason, - createFile: { presentPrompt(.createFile, basePath: parentPath, node: nil) }, - createFolder: { presentPrompt(.createFolder, basePath: parentPath, node: nil) } - ) - .filesListRow() - - if showDisconnectedNotice && !isLive { - disconnectedNotice.filesListRow() - } - - if let actionErrorMessage { - ADENoticeCard( - title: "File action failed", - message: actionErrorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: nil, - action: nil - ) - .filesListRow() - } - - if let errorMessage { - ADENoticeCard( - title: "Directory load failed", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload() } } - ) - .filesListRow() - } - - if isLoading { - ForEach(0..<4, id: \.self) { _ in - ADECardSkeleton(rows: 2) - .filesListRow() - } - } else if nodes.isEmpty { - ADEEmptyStateView( - symbol: parentPath.isEmpty ? "folder" : "folder.badge.minus", - title: parentPath.isEmpty ? "Workspace is empty" : "Folder is empty", - message: isLive ? "This directory does not have any files to preview on iPhone yet." : "Reconnect to load files from the host." - ) - .filesListRow() - } - - ForEach(nodes) { node in - Button { - open(node) - } label: { - FilesTreeNodeRow( - node: node, - transitionNamespace: transitionNamespace, - isSelectedTransitionSource: selectedFilePath == node.path - ) - } - .buttonStyle(.plain) - .contextMenu { - contextMenu(for: node) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if canMutateFiles { - Button("Rename") { - presentPrompt(.rename, basePath: parentDirectory(of: node.path), node: node) - } - .tint(ADEColor.accent) - - Button("Delete", role: .destructive) { - destructiveConfirmation = FilesDestructiveConfirmation(kind: .delete(node: node)) - } - } - } - .filesListRow() - } - } - .task(id: DirectoryReloadKey(workspaceId: workspace.id, parentPath: parentPath, includeHidden: showHidden, live: isLive, revision: syncService.localStateRevision)) { - await reload() - } - .alert(prompt?.title ?? "", isPresented: Binding( - get: { prompt != nil }, - set: { if !$0 { prompt = nil } } - )) { - TextField(prompt?.placeholder ?? "Name", text: $promptValue) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button(prompt?.confirmLabel ?? "Save") { - Task { - await confirmPrompt() - } - } - Button("Cancel", role: .cancel) { - prompt = nil - } - } message: { - Text(prompt?.message ?? "") - } - .alert(item: $destructiveConfirmation) { confirmation in - Alert( - title: Text(confirmation.title), - message: Text(confirmation.message), - primaryButton: .destructive(Text(confirmation.confirmLabel)) { - Task { - await confirmDestructiveAction(confirmation) - } - }, - secondaryButton: .cancel() - ) - } - } - - @MainActor - private func reload() async { - guard isLive else { - isLoading = false - return - } - - do { - isLoading = true - nodes = try await syncService.listTree(workspaceId: workspace.id, parentPath: parentPath, includeIgnored: showHidden) - if let laneId = workspace.laneId { - let changes = try await syncService.fetchLaneChanges(laneId: laneId) - gitState = FilesGitState( - staged: Set(changes.staged.map(\.path)), - unstaged: Set(changes.unstaged.map(\.path)) - ) - } else { - gitState = .empty - } - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - isLoading = false - } - - private func open(_ node: FileTreeNode) { - if node.type == "directory" { - openDirectory(node.path) - } else { - openFile(node.path, nil) - } - } - - private func contextMenu(for node: FileTreeNode) -> some View { - Group { - Button("Open") { - open(node) - } - - Button("Copy Path") { - UIPasteboard.general.string = absolutePath(for: node.path) - } - - Button("Copy Relative Path") { - UIPasteboard.general.string = node.path - } - - if node.type == "directory" && canMutateFiles { - Button("New File") { - presentPrompt(.createFile, basePath: node.path, node: nil) - } - - Button("New Folder") { - presentPrompt(.createFolder, basePath: node.path, node: nil) - } - } - - Button("Rename") { - presentPrompt(.rename, basePath: parentDirectory(of: node.path), node: node) - } - .disabled(!canMutateFiles) - - Button("Delete", role: .destructive) { - destructiveConfirmation = FilesDestructiveConfirmation(kind: .delete(node: node)) - } - .disabled(!canMutateFiles) - - if node.type == "file", let laneId = workspace.laneId { - Button("Stage") { - Task { await stage(node.path, laneId: laneId) } - } - .disabled(!canUseGitActions || !gitState.isUnstaged(node.path)) - - Button("Unstage") { - Task { await unstage(node.path, laneId: laneId) } - } - .disabled(!canUseGitActions || !gitState.isStaged(node.path)) - - Button("Discard Changes", role: .destructive) { - destructiveConfirmation = FilesDestructiveConfirmation(kind: .discard(path: node.path)) - } - .disabled(!canUseGitActions || !gitState.isUnstaged(node.path)) - } - } - } - - private func presentPrompt(_ kind: FilesPromptKind, basePath: String, node: FileTreeNode?) { - prompt = FilesPathPrompt(kind: kind, basePath: basePath, node: node) - promptValue = node?.name ?? "" - } - - @MainActor - private func confirmPrompt() async { - guard let prompt else { return } - let trimmed = promptValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard validatePromptValue(trimmed, prompt: prompt) else { return } - - let targetPath = joinedPath(base: prompt.basePath, name: trimmed) - do { - switch prompt.kind { - case .createFile: - try await syncService.createFile(workspaceId: workspace.id, path: targetPath, content: "") - self.prompt = nil - await reload() - openFile(targetPath, nil) - case .createFolder: - try await syncService.createDirectory(workspaceId: workspace.id, path: targetPath) - self.prompt = nil - await reload() - case .rename: - guard let node = prompt.node else { return } - try await syncService.renamePath(workspaceId: workspace.id, oldPath: node.path, newPath: targetPath) - self.prompt = nil - await reload() - } - actionErrorMessage = nil - } catch { - actionErrorMessage = error.localizedDescription - } - } - - @MainActor - private func confirmDestructiveAction(_ confirmation: FilesDestructiveConfirmation) async { - switch confirmation.kind { - case .delete(let node): - do { - try await syncService.deletePath(workspaceId: workspace.id, path: node.path) - await reload() - actionErrorMessage = nil - } catch { - actionErrorMessage = error.localizedDescription - } - case .discard(let path): - guard let laneId = workspace.laneId else { return } - do { - try await syncService.discardFile(laneId: laneId, path: path) - await reload() - actionErrorMessage = nil - } catch { - actionErrorMessage = error.localizedDescription - } - case .discardUnsaved: - break - } - } - - @MainActor - private func stage(_ path: String, laneId: String) async { - do { - try await syncService.stageFile(laneId: laneId, path: path) - await reload() - actionErrorMessage = nil - } catch { - actionErrorMessage = error.localizedDescription - } - } - - @MainActor - private func unstage(_ path: String, laneId: String) async { - do { - try await syncService.unstageFile(laneId: laneId, path: path) - await reload() - actionErrorMessage = nil - } catch { - actionErrorMessage = error.localizedDescription - } - } - - private func validatePromptValue(_ value: String, prompt: FilesPathPrompt) -> Bool { - guard !value.isEmpty else { - actionErrorMessage = "Name cannot be empty." - return false - } - guard !value.contains("/") && !value.contains("\\") else { - actionErrorMessage = "Use a single file or folder name here." - return false - } - if let conflict = nodes.first(where: { - $0.name.caseInsensitiveCompare(value) == .orderedSame && $0.path != prompt.node?.path - }) { - actionErrorMessage = "\(conflict.name) already exists in this folder." - return false - } - actionErrorMessage = nil - return true - } - - private func absolutePath(for relativePath: String) -> String { - guard !relativePath.isEmpty else { return workspace.rootPath } - return (workspace.rootPath as NSString).appendingPathComponent(relativePath) - } - - private var mutationDisabledReason: String? { - if workspace.isReadOnlyByDefault { - return "This workspace stays read-only on the host." - } - if !isLive { - return needsRepairing - ? "Pair again before creating, renaming, or deleting files." - : "Reconnect before creating, renaming, or deleting files." - } - return nil - } - - private var disconnectedNotice: ADENoticeCard { - ADENoticeCard( - title: nodes.isEmpty ? "Reconnect to load this folder" : "Showing cached directory", - message: needsRepairing - ? "The previous host trust was cleared. Pair again before trusting or editing file state." - : "Edits and refresh are disabled until the host reconnects.", - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible() - } - } - } - ) - } - - private struct DirectoryReloadKey: Hashable { - let workspaceId: String - let parentPath: String - let includeHidden: Bool - let live: Bool - let revision: Int - } -} - -private struct FileEditorView: View { - @EnvironmentObject private var syncService: SyncService - @Environment(\.dismiss) private var dismiss - - let workspace: FilesWorkspace - let relativePath: String - let focusLine: Int? - let isFilesLive: Bool - let needsRepairing: Bool - let transitionNamespace: Namespace.ID? - let navigateToDirectory: (String) -> Void - - @State private var blob: SyncFileBlob? - @State private var draftText = "" - @State private var errorMessage: String? - @State private var metadata: FilesFileMetadata? - @State private var gitState = FilesGitState.empty - @State private var mode: FilesEditorMode = .preview - @State private var diffMode: FilesDiffMode = .unstaged - @State private var diff: FileDiff? - @State private var diffErrorMessage: String? - @State private var saveTrigger = 0 - @State private var isMetadataExpanded = true - @State private var pendingDestructiveConfirmation: FilesDestructiveConfirmation? - @State private var pendingNavigationTarget: EditorNavigationTarget? - - private enum EditorNavigationTarget { - case dismiss - case directory(String) - } - - private var language: FilesLanguage { - FilesLanguage.detect(languageId: blob?.languageId, filePath: relativePath) - } - - private var isImagePreviewable: Bool { - let lowercased = relativePath.lowercased() - return ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff"].contains((lowercased as NSString).pathExtension) - } - - private var imageData: Data? { - guard let blob else { return nil } - if blob.encoding.lowercased() == "base64" { - return Data(base64Encoded: blob.content) - } - return Data(blob.content.utf8) - } - - private var imageCacheKey: String { - "files-preview::\(workspace.id)::\(relativePath)" - } - - private var canEdit: Bool { - isFilesLive && !workspace.isReadOnlyByDefault && blob?.isBinary == false - } - - private var isDirty: Bool { - guard let blob, !blob.isBinary else { return false } - return draftText != blob.content - } - - private var editorModes: [FilesEditorMode] { - guard blob?.isBinary == false else { return [.preview] } - if workspace.laneId != nil { - return [.preview, .edit, .diff] - } - return [.preview, .edit] - } - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - FilesBreadcrumbBar( - relativePath: relativePath, - includeCurrentFile: true, - onSelectDirectory: { path in - attemptNavigation(.directory(path)) - } - ) - - if !isFilesLive { - disconnectedNotice - } - - if let errorMessage { - ADENoticeCard( - title: "File load failed", - message: errorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await load() } } - ) - } - - if blob == nil && errorMessage == nil { - ADECardSkeleton(rows: 4) - } - - if let blob { - filesHeader(blob: blob) - - DisclosureGroup(isExpanded: $isMetadataExpanded) { - VStack(alignment: .leading, spacing: 10) { - FilesMetadataRow(label: "Path", value: relativePath) - FilesMetadataRow(label: "Size", value: metadata?.sizeText ?? formattedFileSize(blob.size)) - FilesMetadataRow(label: "Language", value: metadata?.languageLabel ?? language.displayName) - FilesMetadataRow(label: "Last commit", value: metadata?.lastCommitTitle ?? "No commit information available") - if let lastCommitDateText = metadata?.lastCommitDateText { - FilesMetadataRow(label: "Last change", value: lastCommitDateText) - } - } - .padding(.top, 10) - } label: { - Text("Metadata") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - } - .adeGlassCard(cornerRadius: 18) - - if blob.isBinary { - binaryPreview(blob: blob) - } else { - codeSurface(blob: blob) - } - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle(lastPathComponent(relativePath)) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { - attemptNavigation(.dismiss) - } label: { - Image(systemName: "chevron.left") - } - .accessibilityLabel("Back") - } - - ToolbarItemGroup(placement: .topBarTrailing) { - if isDirty { - ADEStatusPill(text: "UNSAVED", tint: ADEColor.warning) - } - - if canEdit { - Button("Save") { - Task { await save() } - } - .disabled(!isDirty) - } - } - } - .sensoryFeedback(.success, trigger: saveTrigger) - .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "files-container-\(relativePath)", in: transitionNamespace) - .task { - await load() - } - .task(id: syncService.localStateRevision) { - await load(refreshDiff: mode == .diff) - } - .task(id: mode) { - if mode == .diff { - await loadDiff() - } - } - .task(id: diffMode) { - if mode == .diff { - await loadDiff() - } - } - .alert(item: $pendingDestructiveConfirmation) { confirmation in - Alert( - title: Text(confirmation.title), - message: Text(confirmation.message), - primaryButton: .destructive(Text(confirmation.confirmLabel)) { - switch confirmation.kind { - case .discardUnsaved: - performNavigationTarget() - case .discard(let path): - guard let laneId = workspace.laneId else { return } - Task { - do { - try await syncService.discardFile(laneId: laneId, path: path) - await load(refreshDiff: true) - } catch { - errorMessage = error.localizedDescription - } - } - case .delete: - break - } - }, - secondaryButton: .cancel() - ) - } - } - - @ViewBuilder - private func filesHeader(blob: SyncFileBlob) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: fileIcon(for: relativePath)) - .font(.title3.weight(.semibold)) - .foregroundStyle(fileTint(for: relativePath)) - .frame(width: 42, height: 42) - .background(ADEColor.surfaceBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 12)) - .adeMatchedGeometry(id: transitionNamespace == nil ? nil : "files-icon-\(relativePath)", in: transitionNamespace) - - VStack(alignment: .leading, spacing: 6) { - Text(lastPathComponent(relativePath)) - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - .adeMatchedGeometry(id: transitionNamespace == nil ? nil : "files-title-\(relativePath)", in: transitionNamespace) - Text(parentDirectory(of: relativePath).isEmpty ? "Workspace root" : parentDirectory(of: relativePath)) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - } - - Spacer(minLength: 0) - } - - ScrollView(.horizontal, showsIndicators: false) { - ADEGlassGroup(spacing: 8) { - ADEStatusPill(text: language.displayName.uppercased(), tint: ADEColor.accent) - if workspace.isReadOnlyByDefault { - ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) - } else if !isFilesLive { - ADEStatusPill(text: "DISCONNECTED", tint: ADEColor.warning) - } - if let laneId = workspace.laneId, gitState.isUnstaged(relativePath) || gitState.isStaged(relativePath) { - FilesGitActionGroup( - laneId: laneId, - path: relativePath, - gitState: gitState, - stage: { Task { await stageCurrentFile(laneId: laneId) } }, - unstage: { Task { await unstageCurrentFile(laneId: laneId) } }, - discard: { pendingDestructiveConfirmation = FilesDestructiveConfirmation(kind: .discard(path: relativePath)) } - ) - } - } - } - } - .adeGlassCard(cornerRadius: 18) - } - - @ViewBuilder - private func binaryPreview(blob: SyncFileBlob) -> some View { - if isImagePreviewable, let data = imageData, let image = UIImage(data: data) { - VStack(alignment: .leading, spacing: 10) { - Text("Preview") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - ZoomableImageView(image: image) - .frame(minHeight: 280) - } - .adeGlassCard(cornerRadius: 18) - } else if isImagePreviewable { - ADENoticeCard( - title: "Image preview unavailable", - message: "The current host only exposed file metadata for this image. Reconnect and reopen after the host sends binary bytes for previews.", - icon: "photo", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } else { - ADEEmptyStateView( - symbol: "doc.fill", - title: "Binary file", - message: "This file cannot be displayed inline on iPhone yet." - ) - } - } - - @ViewBuilder - private func codeSurface(blob: SyncFileBlob) -> some View { - VStack(alignment: .leading, spacing: 12) { - Picker("Mode", selection: $mode) { - ForEach(editorModes) { editorMode in - Text(editorMode.title).tag(editorMode) - } - } - .pickerStyle(.segmented) - - switch mode { - case .preview: - SyntaxHighlightedCodeView( - text: draftText, - language: language, - focusLine: focusLine - ) - case .edit: - VStack(alignment: .leading, spacing: 10) { - if workspace.isReadOnlyByDefault { - Text("This workspace is edit-protected on the host.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } else if !isFilesLive { - Text("Reconnect to a live host before editing or saving file contents.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - - TextEditor(text: $draftText) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 320) - .disabled(!canEdit) - .adeInsetField(cornerRadius: 16, padding: 12) - } - case .diff: - VStack(alignment: .leading, spacing: 10) { - if workspace.laneId == nil { - Text("Diff mode requires a lane-backed workspace.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } else { - Picker("Diff", selection: $diffMode) { - ForEach(FilesDiffMode.allCases) { item in - Text(item.title).tag(item) - } - } - .pickerStyle(.segmented) - - if let diffErrorMessage { - ADENoticeCard( - title: "Diff unavailable", - message: diffErrorMessage, - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await loadDiff() } } - ) - } else if let diff, diff.isBinary == true { - ADEEmptyStateView( - symbol: "doc.badge.gearshape", - title: "Binary diff", - message: "This file changed, but the host reported a binary diff that cannot be rendered inline." - ) - } else if let diff { - FilesInlineDiffView( - lines: buildInlineDiffLines(original: diff.original.text, modified: diff.modified.text), - language: FilesLanguage.detect(languageId: diff.language, filePath: relativePath) - ) - } else { - ADECardSkeleton(rows: 4) - } - } - } - } - } - .adeGlassCard(cornerRadius: 18) - } - - @MainActor - private func load(refreshDiff: Bool = false) async { - do { - if isImagePreviewable, let cachedData = ADEImageCache.shared.cachedData(for: imageCacheKey) { - let cachedBlob = SyncFileBlob( - path: relativePath, - size: cachedData.count, - mimeType: nil, - encoding: "base64", - isBinary: true, - content: cachedData.base64EncodedString(), - languageId: nil - ) - blob = cachedBlob - await loadGitState() - await loadMetadata(from: cachedBlob) - if refreshDiff { - await loadDiff() - } - errorMessage = nil - return - } - - let wasDirty = isDirty - let loaded = try await syncService.readFile(workspaceId: workspace.id, path: relativePath) - blob = loaded - if loaded.isBinary, isImagePreviewable, let data = imageData { - ADEImageCache.shared.store(data, for: imageCacheKey) - } - if !loaded.isBinary && (!wasDirty || draftText.isEmpty) { - draftText = loaded.content - } - await loadGitState() - await loadMetadata(from: loaded) - if refreshDiff { - await loadDiff() - } - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func loadGitState() async { - guard let laneId = workspace.laneId, isFilesLive else { return } - do { - let changes = try await syncService.fetchLaneChanges(laneId: laneId) - gitState = FilesGitState( - staged: Set(changes.staged.map(\.path)), - unstaged: Set(changes.unstaged.map(\.path)) - ) - } catch { - // Preserve current git state if fetch fails. - } - } - - @MainActor - private func loadMetadata(from blob: SyncFileBlob) async { - var lastCommitTitle: String? - var lastCommitDateText: String? - - if let laneId = workspace.laneId, isFilesLive { - do { - let commits = try await syncService.listRecentCommits(laneId: laneId) - for commit in commits.prefix(25) { - let files = try await syncService.listCommitFiles(laneId: laneId, commitSha: commit.sha) - if files.contains(relativePath) { - lastCommitTitle = commit.subject - lastCommitDateText = relativeDateDescription(from: commit.authoredAt) - break - } - } - } catch { - // Best-effort metadata. - } - } - - metadata = FilesFileMetadata( - sizeText: formattedFileSize(blob.size), - languageLabel: language.displayName, - lastCommitTitle: lastCommitTitle, - lastCommitDateText: lastCommitDateText - ) - } - - @MainActor - private func loadDiff() async { - guard let laneId = workspace.laneId, isFilesLive else { - diff = nil - diffErrorMessage = nil - return - } - do { - diff = try await syncService.fetchFileDiff(laneId: laneId, path: relativePath, mode: diffMode.rawValue) - diffErrorMessage = nil - } catch { - diffErrorMessage = error.localizedDescription - } - } - - @MainActor - private func save() async { - guard canEdit else { return } - do { - try await syncService.writeText(workspaceId: workspace.id, path: relativePath, text: draftText) - saveTrigger += 1 - await load(refreshDiff: mode == .diff) - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func stageCurrentFile(laneId: String) async { - do { - try await syncService.stageFile(laneId: laneId, path: relativePath) - await load(refreshDiff: true) - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func unstageCurrentFile(laneId: String) async { - do { - try await syncService.unstageFile(laneId: laneId, path: relativePath) - await load(refreshDiff: true) - } catch { - errorMessage = error.localizedDescription - } - } - - private func attemptNavigation(_ target: EditorNavigationTarget) { - guard isDirty else { - performNavigation(target) - return - } - pendingNavigationTarget = target - pendingDestructiveConfirmation = FilesDestructiveConfirmation(kind: .discardUnsaved) - } - - private func performNavigationTarget() { - if let target = pendingNavigationTarget { - performNavigation(target) - pendingNavigationTarget = nil - } - } - - private func performNavigation(_ target: EditorNavigationTarget) { - switch target { - case .dismiss: - dismiss() - case .directory(let path): - navigateToDirectory(path) - } - } - - private var disconnectedNotice: ADENoticeCard { - ADENoticeCard( - title: "Read-only while disconnected", - message: needsRepairing - ? "Pair again before trusting file state or saving edits." - : "The last-loaded file content stays visible, but editing and file operations are disabled until the host reconnects.", - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible() - } - } - } - ) - } -} - -private struct FilesWorkspaceHeader: View { - let workspaces: [FilesWorkspace] - @Binding var selectedWorkspaceId: String - let selectedWorkspace: FilesWorkspace - @Binding var showHidden: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Picker("Workspace", selection: $selectedWorkspaceId) { - ForEach(workspaces) { workspace in - Text(workspace.name).tag(workspace.id) - } - } - .pickerStyle(.menu) - - VStack(alignment: .leading, spacing: 8) { - Text(selectedWorkspace.rootPath) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .textSelection(.enabled) - - ScrollView(.horizontal, showsIndicators: false) { - ADEGlassGroup(spacing: 8) { - ADEStatusPill(text: selectedWorkspace.kind.uppercased(), tint: ADEColor.accent) - if selectedWorkspace.isReadOnlyByDefault { - ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) - } - Button { - showHidden.toggle() - } label: { - Label(showHidden ? "Hide dotfiles" : "Show dotfiles", systemImage: showHidden ? "eye.slash" : "eye") - .font(.caption.weight(.semibold)) - } - .buttonStyle(.glass) - .accessibilityLabel(showHidden ? "Hide hidden files" : "Show hidden files") - } - } - } - } - .adeGlassCard(cornerRadius: 18) - } -} - -private struct FilesQueryCard: View { - let title: String - let prompt: String - @Binding var query: String - let disabled: Bool - let emptyMessage: String - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - TextField(prompt, text: $query) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .disabled(disabled) - .adeInsetField() - Text(emptyMessage) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - .adeGlassCard(cornerRadius: 18) - } -} - -private struct FilesDirectoryActionRow: View { - let canMutateFiles: Bool - let mutationDisabledReason: String? - let createFile: () -> Void - let createFolder: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Folder actions") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - - ADEGlassGroup(spacing: 10) { - Button(action: createFile) { - Label("New file", systemImage: "doc.badge.plus") - .font(.caption.weight(.semibold)) - } - .buttonStyle(.glass) - .disabled(!canMutateFiles) - - Button(action: createFolder) { - Label("New folder", systemImage: "folder.badge.plus") - .font(.caption.weight(.semibold)) - } - .buttonStyle(.glass) - .disabled(!canMutateFiles) - } - - if let mutationDisabledReason { - Text(mutationDisabledReason) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - .adeGlassCard(cornerRadius: 18) - } -} - -private struct FilesTreeNodeRow: View { - let node: FileTreeNode - let transitionNamespace: Namespace.ID? - let isSelectedTransitionSource: Bool - - var body: some View { - HStack(spacing: 12) { - Image(systemName: node.type == "directory" ? "folder.fill" : fileIcon(for: node.name)) - .font(.headline) - .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) - .frame(width: 22) - .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) - - VStack(alignment: .leading, spacing: 4) { - Text(node.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) - Text(node.path.isEmpty ? (node.type == "directory" ? "Folder" : "File") : node.path) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - if let size = node.size, node.type == "file" { - Text(formattedFileSize(size)) - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) - } - - if let changeStatus = node.changeStatus { - ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) - } - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - } - .adeListCard(cornerRadius: 16) - .adeMatchedTransitionSource(id: canTransition ? "files-container-\(node.path)" : nil, in: transitionNamespace) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - } - - private var canTransition: Bool { - node.type == "file" && isSelectedTransitionSource - } - - private var accessibilityLabel: String { - if let changeStatus = node.changeStatus { - return "\(node.name), \(node.type), \(changeStatusDescription(changeStatus))" - } - return "\(node.name), \(node.type)" - } -} - -private struct FilesResultRow: View { - let path: String - let transitionNamespace: Namespace.ID? - let isSelectedTransitionSource: Bool - - var body: some View { - HStack(spacing: 10) { - Image(systemName: fileIcon(for: path)) - .foregroundStyle(fileTint(for: path)) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-icon-\(path)" : nil, in: transitionNamespace) - VStack(alignment: .leading, spacing: 3) { - Text(lastPathComponent(path)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-title-\(path)" : nil, in: transitionNamespace) - Text(path) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - } - .adeListCard(cornerRadius: 16) - .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "files-container-\(path)" : nil, in: transitionNamespace) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(lastPathComponent(path)), file") - } -} - -private struct FilesSearchResultRow: View { - let result: FilesSearchTextMatch - let transitionNamespace: Namespace.ID? - let isSelectedTransitionSource: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: fileIcon(for: result.path)) - .foregroundStyle(fileTint(for: result.path)) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-icon-\(result.path)" : nil, in: transitionNamespace) - Text(lastPathComponent(result.path)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-title-\(result.path)" : nil, in: transitionNamespace) - Spacer() - ADEStatusPill(text: "L\(result.line)", tint: ADEColor.accent) - } - Text(result.path) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - Text(result.preview) - .font(.caption) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - } - .adeListCard(cornerRadius: 16) - .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "files-container-\(result.path)" : nil, in: transitionNamespace) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(lastPathComponent(result.path)), line \(result.line)") - } -} - -private struct FilesBreadcrumbBar: View { - let relativePath: String - let includeCurrentFile: Bool - let onSelectDirectory: (String) -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - Button("root") { - onSelectDirectory("") - } - .buttonStyle(.glass) - - ForEach(Array(breadcrumbs.enumerated()), id: \.offset) { index, breadcrumb in - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - - if breadcrumb.isDirectory { - Button(breadcrumb.label) { - onSelectDirectory(breadcrumb.path) - } - .buttonStyle(.glass) - } else { - Text(breadcrumb.label) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(ADEColor.surfaceBackground, in: Capsule()) - .glassEffect() - } - } - } - .padding(4) - } - .adeGlassCard(cornerRadius: 18, padding: 12) - } - - private var breadcrumbs: [(label: String, path: String, isDirectory: Bool)] { - let components = pathComponents(relativePath) - guard !components.isEmpty else { return [] } - return components.indices.map { index in - let path = components[0...index].joined(separator: "/") - let isLast = index == components.count - 1 - return (components[index], path, includeCurrentFile ? !isLast : true) - } - } -} - -private struct FilesGitActionGroup: View { - let laneId: String - let path: String - let gitState: FilesGitState - let stage: () -> Void - let unstage: () -> Void - let discard: () -> Void - - var body: some View { - ADEGlassGroup(spacing: 8) { - if gitState.isUnstaged(path) { - Button("Stage", action: stage) - .buttonStyle(.glass) - } - if gitState.isStaged(path) { - Button("Unstage", action: unstage) - .buttonStyle(.glass) - } - if gitState.isUnstaged(path) { - Button("Discard", role: .destructive, action: discard) - .buttonStyle(.glass) - } - } - } -} - -private struct FilesMetadataRow: View { - let label: String - let value: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - Text(value) - .font(label == "Path" ? .caption.monospaced() : .subheadline) - .foregroundStyle(ADEColor.textPrimary) - .textSelection(.enabled) - } - } -} - -private struct SyntaxHighlightedCodeView: View { - let text: String - let language: FilesLanguage - let focusLine: Int? - - private var lines: [String] { - let split = splitPreservingEmptyLines(text) - return split.isEmpty ? [""] : split - } - - var body: some View { - ScrollViewReader { proxy in - ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(Array(lines.enumerated()), id: \.offset) { index, line in - HStack(alignment: .top, spacing: 12) { - Text("\(index + 1)") - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) - .frame(minWidth: 36, alignment: .trailing) - Text(SyntaxHighlighter.highlightedAttributedString(line.isEmpty ? " " : line, as: language)) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - .fixedSize(horizontal: true, vertical: false) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background((focusLine == index + 1 ? ADEColor.accent.opacity(0.12) : Color.clear), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .id(index + 1) - } - } - .padding(10) - } - .frame(minHeight: 320) - .adeInsetField(cornerRadius: 16, padding: 0) - .task(id: focusLine) { - guard let focusLine else { return } - withAnimation(.smooth) { - proxy.scrollTo(focusLine, anchor: .center) - } - } - } - } -} - -private struct FilesInlineDiffView: View { - let lines: [FilesInlineDiffLine] - let language: FilesLanguage - - var body: some View { - ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(lines) { line in - HStack(alignment: .top, spacing: 12) { - diffLineNumber(line.originalLineNumber) - diffLineNumber(line.modifiedLineNumber) - Text(SyntaxHighlighter.highlightedAttributedString(line.text.isEmpty ? " " : line.text, as: language)) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - .fixedSize(horizontal: true, vertical: false) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(diffBackground(for: line.kind), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - .padding(10) - } - .frame(minHeight: 320) - .adeInsetField(cornerRadius: 16, padding: 0) - } - - private func diffLineNumber(_ lineNumber: Int?) -> some View { - Text(lineNumber.map(String.init) ?? "•") - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) - .frame(minWidth: 32, alignment: .trailing) - } - - private func diffBackground(for kind: FilesInlineDiffKind) -> Color { - switch kind { - case .unchanged: - return Color.clear - case .added: - return ADEColor.success.opacity(0.12) - case .removed: - return ADEColor.danger.opacity(0.12) - } - } -} - -private struct ZoomableImageView: View { - let image: UIImage - - @State private var scale: CGFloat = 1 - @State private var lastScale: CGFloat = 1 - @State private var offset: CGSize = .zero - @State private var lastOffset: CGSize = .zero - - var body: some View { - GeometryReader { proxy in - Image(uiImage: image) - .resizable() - .scaledToFit() - .scaleEffect(scale) - .offset(offset) - .frame(width: proxy.size.width, height: proxy.size.height) - .contentShape(Rectangle()) - .gesture(magnificationGesture.simultaneously(with: dragGesture)) - } - .adeInsetField(cornerRadius: 16, padding: 0) - } - - private var magnificationGesture: some Gesture { - MagnificationGesture() - .onChanged { value in - scale = min(max(lastScale * value, 1), 6) - } - .onEnded { _ in - lastScale = scale - if scale <= 1 { - offset = .zero - lastOffset = .zero - } - } - } - - private var dragGesture: some Gesture { - DragGesture() - .onChanged { value in - guard scale > 1 else { return } - offset = CGSize(width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height) - } - .onEnded { _ in - guard scale > 1 else { - offset = .zero - lastOffset = .zero - return - } - lastOffset = offset - } - } -} - -private extension View { - func filesListRow() -> some View { - listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } -} - -private func joinedPath(base: String, name: String) -> String { - let cleanedBase = base.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let cleanedName = name.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !cleanedBase.isEmpty else { return cleanedName } - guard !cleanedName.isEmpty else { return cleanedBase } - return "\(cleanedBase)/\(cleanedName)" -} - -private func parentDirectory(of path: String) -> String { - let components = pathComponents(path) - guard components.count > 1 else { return "" } - return components.dropLast().joined(separator: "/") -} - -private func pathComponents(_ path: String) -> [String] { - path - .split(separator: "/") - .map(String.init) -} - -private func lastPathComponent(_ path: String) -> String { - pathComponents(path).last ?? path -} - -private func fileTint(for name: String) -> Color { - let icon = fileIcon(for: name) - switch icon { - case "chevron.left.forwardslash.chevron.right": - return .blue - case "doc.badge.gearshape": - return .orange - case "doc.text": - return .yellow - case "photo": - return .pink - case "doc.zipper": - return .red - default: - return ADEColor.textSecondary - } -} - -private func changeStatusTint(_ changeStatus: String) -> Color { - switch changeStatus.uppercased() { - case "A": - return ADEColor.success - case "D": - return ADEColor.danger - case "M": - return ADEColor.warning - default: - return ADEColor.textSecondary - } -} - -private func changeStatusDescription(_ changeStatus: String) -> String { - switch changeStatus.uppercased() { - case "A": - return "Added" - case "D": - return "Deleted" - case "M": - return "Modified" - default: - return changeStatus.uppercased() - } -} - -private func relativeDateDescription(from isoTimestamp: String?) -> String? { - guard let isoTimestamp, let date = ISO8601DateFormatter().date(from: isoTimestamp) else { - return nil - } - return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) -} diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 1f0e07023..d7164db7d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1407,6 +1407,86 @@ final class ADETests: XCTestCase { XCTAssertEqual(formattedFileSize(1_572_864), "1.5 MB") } + func testFilesNameValidationRejectsInvalidAndDuplicateNames() { + let existingNodes = [ + FileTreeNode(name: "Config", path: "Config", type: "directory", hasChildren: nil, children: nil, changeStatus: nil, size: nil), + FileTreeNode(name: "README.md", path: "README.md", type: "file", hasChildren: nil, children: nil, changeStatus: nil, size: 16), + ] + + XCTAssertEqual(filesNameValidationError(for: "", existingNodes: existingNodes), "Name cannot be empty.") + XCTAssertEqual(filesNameValidationError(for: ".", existingNodes: existingNodes), "That name is reserved.") + XCTAssertEqual(filesNameValidationError(for: "bad/name", existingNodes: existingNodes), "Use a single file or folder name here.") + XCTAssertEqual(filesNameValidationError(for: "bad\0name", existingNodes: existingNodes), "Use a single file or folder name here.") + XCTAssertEqual(filesNameValidationError(for: "config", existingNodes: existingNodes), "Config already exists in this folder.") + XCTAssertNil(filesNameValidationError(for: "NewFile.swift", existingNodes: existingNodes)) + } + + func testVisibleFilesTreeRowsExpandsChildrenInPlace() { + let rootNodes = [ + FileTreeNode(name: "Sources", path: "Sources", type: "directory", hasChildren: true, children: nil, changeStatus: nil, size: nil), + FileTreeNode(name: "README.md", path: "README.md", type: "file", hasChildren: nil, children: nil, changeStatus: nil, size: 42), + ] + let childNodes = [ + FileTreeNode(name: "App.swift", path: "Sources/App.swift", type: "file", hasChildren: nil, children: nil, changeStatus: nil, size: 128), + ] + + let rows = visibleFilesTreeRows( + nodes: rootNodes, + expandedPaths: ["Sources"], + loadingPaths: [], + childNodesByPath: ["Sources": childNodes], + showHidden: true + ) + + XCTAssertEqual(rows.map(\.id), ["Sources", "Sources/App.swift", "README.md"]) + XCTAssertEqual(rows[1].depth, 1) + } + + func testBinaryFilePathDetectsCommonBinaryExtensions() { + XCTAssertTrue(isBinaryFilePath("build/tool.bin")) + XCTAssertTrue(isBinaryFilePath("Frameworks/libsqlite3.dylib")) + XCTAssertFalse(isBinaryFilePath("Sources/App.swift")) + } + + func testFilesBreadcrumbItemsIncludesCurrentFileOnlyWhenRequested() { + let fileBreadcrumbs = filesBreadcrumbItems( + relativePath: "Sources/Views/FileTreeView.swift", + includeCurrentFile: true + ) + XCTAssertEqual(fileBreadcrumbs.map(\.label), ["Sources", "Views", "FileTreeView.swift"]) + XCTAssertEqual(fileBreadcrumbs.map(\.path), ["Sources", "Sources/Views", "Sources/Views/FileTreeView.swift"]) + XCTAssertEqual(fileBreadcrumbs.map(\.isDirectory), [true, true, false]) + + let directoryBreadcrumbs = filesBreadcrumbItems( + relativePath: "Sources/Views", + includeCurrentFile: false + ) + XCTAssertEqual(directoryBreadcrumbs.map(\.isDirectory), [true, true]) + } + + func testFileViewerHelpersComputeLineNumbersAndFindReplaceMatches() { + let text = "alpha\nbeta\nbeta" + + XCTAssertEqual(fileViewerLineCount(for: text), 3) + XCTAssertEqual(fileViewerLineNumbersText(for: text), "1\n2\n3") + + let matches = fileViewerFindMatches(in: text, query: "beta") + XCTAssertEqual(matches.count, 2) + XCTAssertEqual(fileViewerMatchIndex(containing: matches[1], in: matches), 1) + + let replacedCurrent = fileViewerReplaceCurrentMatch( + in: text, + query: "beta", + replacement: "gamma", + matchIndex: 1 + ) + XCTAssertEqual(replacedCurrent?.text, "alpha\nbeta\ngamma") + XCTAssertEqual(replacedCurrent?.selection, NSRange(location: 11, length: 5)) + + let replacedAll = fileViewerReplaceAllMatches(in: text, query: "beta", replacement: "gamma") + XCTAssertEqual(replacedAll, "alpha\ngamma\ngamma") + } + func testAgentChatTranscriptResponseDecodesEntries() throws { let payload: [String: Any] = [ "sessionId": "chat-1",