diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 11fd85a..f5c0738 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 575; + CURRENT_PROJECT_VERSION = 577; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -444,7 +444,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 575; + CURRENT_PROJECT_VERSION = 577; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/App/AppMenus.swift b/Neon Vision Editor/App/AppMenus.swift index be3e88e..c38d8c9 100644 --- a/Neon Vision Editor/App/AppMenus.swift +++ b/Neon Vision Editor/App/AppMenus.swift @@ -58,6 +58,11 @@ struct NeonVisionMacAppCommands: Commands { activeEditorViewModel().selectedTab != nil } + private var hasSavableSelectedTab: Bool { + guard let selectedTab = activeEditorViewModel().selectedTab else { return false } + return !selectedTab.isReadOnlyPreview + } + private func post(_ name: Notification.Name, object: Any? = nil) { postWindowCommand(name, object) } @@ -137,7 +142,7 @@ struct NeonVisionMacAppCommands: Commands { } } .keyboardShortcut("s", modifiers: .command) - .disabled(!hasSelectedTab) + .disabled(!hasSavableSelectedTab) Button("Save As…") { let current = activeEditorViewModel() @@ -146,7 +151,7 @@ struct NeonVisionMacAppCommands: Commands { } } .keyboardShortcut("s", modifiers: [.command, .shift]) - .disabled(!hasSelectedTab) + .disabled(!hasSavableSelectedTab) Button("Rename") { let current = activeEditorViewModel() diff --git a/Neon Vision Editor/Core/RemoteSessionStore.swift b/Neon Vision Editor/Core/RemoteSessionStore.swift index d431f34..944e101 100644 --- a/Neon Vision Editor/Core/RemoteSessionStore.swift +++ b/Neon Vision Editor/Core/RemoteSessionStore.swift @@ -4,9 +4,9 @@ import Observation private final class RemoteSessionCompletionGate: @unchecked Sendable { private let lock = NSLock() - private var didComplete = false + nonisolated(unsafe) private var didComplete = false - func claim() -> Bool { + nonisolated func claim() -> Bool { lock.lock() defer { lock.unlock() } guard !didComplete else { return false } @@ -32,6 +32,8 @@ final class RemoteSessionStore { var host: String var username: String var port: Int + var sshKeyBookmarkData: Data? + var sshKeyDisplayName: String var lastPreparedAt: Date init( @@ -40,6 +42,8 @@ final class RemoteSessionStore { host: String, username: String, port: Int, + sshKeyBookmarkData: Data? = nil, + sshKeyDisplayName: String = "", lastPreparedAt: Date = Date() ) { self.id = id @@ -47,6 +51,8 @@ final class RemoteSessionStore { self.host = host self.username = username self.port = port + self.sshKeyBookmarkData = sshKeyBookmarkData + self.sshKeyDisplayName = sshKeyDisplayName self.lastPreparedAt = lastPreparedAt } @@ -62,6 +68,20 @@ final class RemoteSessionStore { } } + struct RemoteFileEntry: Identifiable, Equatable { + let name: String + let path: String + let isDirectory: Bool + + var id: String { path } + } + + struct RemotePreviewDocument: Equatable { + let name: String + let path: String + let content: String + } + static let shared = RemoteSessionStore() private static let savedTargetsKey = "RemoteSessionSavedTargetsV1" @@ -74,7 +94,14 @@ final class RemoteSessionStore { private(set) var runtimeState: RuntimeState = .idle private(set) var sessionStartedAt: Date? = nil private(set) var sessionStatusDetail: String = "" + private(set) var remoteBrowserEntries: [RemoteFileEntry] = [] + private(set) var remoteBrowserPath: String = "~" + private(set) var remoteBrowserStatusDetail: String = "" + private(set) var isRemoteBrowserLoading: Bool = false private var liveConnection: NWConnection? = nil +#if os(macOS) + private var liveSSHProcess: Process? = nil +#endif private let connectionQueue = DispatchQueue(label: "RemoteSessionStore.Connection") private init() { @@ -98,13 +125,21 @@ final class RemoteSessionStore { runtimeState == .connecting } - func connectPreview(nickname: String, host: String, username: String, port: Int) -> SavedTarget? { + func connectPreview( + nickname: String, + host: String, + username: String, + port: Int, + sshKeyBookmarkData: Data? = nil, + sshKeyDisplayName: String = "" + ) -> SavedTarget? { let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedHost.isEmpty else { return nil } let sanitizedPort = min(max(port, 1), 65535) let normalizedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) let displayNickname = normalizedNickname.isEmpty ? trimmedHost : normalizedNickname + let normalizedSSHKeyDisplayName = sshKeyDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let target = SavedTarget( id: existingTargetID(host: trimmedHost, username: normalizedUsername, port: sanitizedPort) ?? UUID(), @@ -112,6 +147,8 @@ final class RemoteSessionStore { host: trimmedHost, username: normalizedUsername, port: sanitizedPort, + sshKeyBookmarkData: sshKeyBookmarkData, + sshKeyDisplayName: normalizedSSHKeyDisplayName, lastPreparedAt: Date() ) @@ -121,6 +158,7 @@ final class RemoteSessionStore { runtimeState = .ready sessionStartedAt = nil sessionStatusDetail = "" + clearRemoteBrowserState() persist() syncLegacyDefaults(with: target) return target @@ -133,6 +171,7 @@ final class RemoteSessionStore { runtimeState = .idle sessionStartedAt = nil sessionStatusDetail = "" + clearRemoteBrowserState() persist() syncLegacyDefaultsForDisconnect() } @@ -146,6 +185,7 @@ final class RemoteSessionStore { runtimeState = .idle sessionStartedAt = nil sessionStatusDetail = "" + clearRemoteBrowserState() syncLegacyDefaultsForDisconnect() } persist() @@ -158,6 +198,7 @@ final class RemoteSessionStore { runtimeState = .ready sessionStartedAt = nil sessionStatusDetail = "" + clearRemoteBrowserState() persist() syncLegacyDefaults(with: target) } @@ -167,6 +208,11 @@ final class RemoteSessionStore { let targetSummary = target.connectionSummary cancelLiveConnection() +#if os(macOS) + if target.sshKeyBookmarkData != nil { + return await startSSHSessionMac(target: target, timeout: timeout) + } +#endif runtimeState = .connecting sessionStartedAt = nil sessionStatusDetail = "Opening a TCP connection to \(targetSummary)…" @@ -231,8 +277,72 @@ final class RemoteSessionStore { runtimeState = activeTarget == nil ? .idle : .ready sessionStartedAt = nil sessionStatusDetail = activeTarget == nil ? "" : "Connection closed. The target stays selected for later." + clearRemoteBrowserState() } +#if os(macOS) + func loadRemoteDirectory(path: String? = nil, timeout: TimeInterval = 8) async -> Bool { + guard isRemotePreviewConnected, let target = activeTarget else { + remoteBrowserStatusDetail = "Start a remote session before browsing files." + return false + } + guard target.sshKeyBookmarkData != nil else { + remoteBrowserStatusDetail = "Remote file browsing requires an SSH-key session on macOS." + return false + } + + let requestedPath = normalizedRemoteBrowserPath(path ?? remoteBrowserPath) + isRemoteBrowserLoading = true + remoteBrowserStatusDetail = "Loading \(requestedPath)…" + + let result = await runRemoteBrowseCommandMac(target: target, path: requestedPath, timeout: timeout) + + isRemoteBrowserLoading = false + switch result { + case .success(let payload): + remoteBrowserPath = payload.path + remoteBrowserEntries = payload.entries + remoteBrowserStatusDetail = payload.entries.isEmpty + ? "No entries found in \(payload.path)." + : "Loaded \(payload.entries.count) entr\(payload.entries.count == 1 ? "y" : "ies") from \(payload.path)." + return true + case .failure(let detail): + remoteBrowserEntries = [] + remoteBrowserStatusDetail = detail + return false + } + } + + func openRemoteFilePreview(path: String, timeout: TimeInterval = 8) async -> RemotePreviewDocument? { + guard isRemotePreviewConnected, let target = activeTarget else { + remoteBrowserStatusDetail = "Start a remote session before opening a remote file." + return nil + } + guard target.sshKeyBookmarkData != nil else { + remoteBrowserStatusDetail = "Remote file preview requires an SSH-key session on macOS." + return nil + } + + let requestedPath = normalizedRemoteBrowserPath(path) + guard EditorViewModel.isSupportedEditorFileURL(URL(fileURLWithPath: requestedPath)) else { + remoteBrowserStatusDetail = "Only supported text files can be opened as a remote preview." + return nil + } + + remoteBrowserStatusDetail = "Opening \(requestedPath)…" + + let result = await runRemoteReadCommandMac(target: target, path: requestedPath, timeout: timeout) + switch result { + case .success(let document): + remoteBrowserStatusDetail = "Opened \(document.name) as a read-only remote preview." + return document + case .failure(let detail): + remoteBrowserStatusDetail = detail + return nil + } + } +#endif + private func upsert(_ target: SavedTarget) { if let existingIndex = savedTargets.firstIndex(where: { $0.id == target.id }) { savedTargets[existingIndex] = target @@ -293,5 +403,361 @@ final class RemoteSessionStore { private func cancelLiveConnection() { liveConnection?.cancel() liveConnection = nil +#if os(macOS) + liveSSHProcess?.terminate() + liveSSHProcess = nil +#endif } + + private func clearRemoteBrowserState() { + remoteBrowserEntries = [] + remoteBrowserPath = "~" + remoteBrowserStatusDetail = "" + isRemoteBrowserLoading = false + } + +#if os(macOS) + private struct RemoteBrowsePayload { + let path: String + let entries: [RemoteFileEntry] + } + + private enum RemoteBrowseResult { + case success(RemoteBrowsePayload) + case failure(String) + } + + private enum RemoteReadResult { + case success(RemotePreviewDocument) + case failure(String) + } + + private func startSSHSessionMac(target: SavedTarget, timeout: TimeInterval) async -> Bool { + guard let bookmarkData = target.sshKeyBookmarkData else { + runtimeState = .failed + sessionStartedAt = nil + sessionStatusDetail = "The selected SSH key is no longer available." + return false + } + guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else { + runtimeState = .failed + sessionStartedAt = nil + sessionStatusDetail = "The selected SSH key could not be resolved. Re-select the key file." + return false + } + + let targetSummary = target.connectionSummary + let didAccessKey = keyURL.startAccessingSecurityScopedResource() + let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)" + let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up))) + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = [ + "-N", + "-i", keyURL.path, + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "NumberOfPasswordPrompts=0", + "-o", "PreferredAuthentications=publickey", + "-o", "PubkeyAuthentication=yes", + "-o", "ConnectTimeout=\(connectTimeoutSeconds)", + "-p", "\(target.port)", + loginTarget + ] + process.standardOutput = Pipe() + process.standardError = stderrPipe + + runtimeState = .connecting + sessionStartedAt = nil + sessionStatusDetail = "Starting an SSH session to \(targetSummary) with the selected key…" + + return await withCheckedContinuation { continuation in + let completionGate = RemoteSessionCompletionGate() + + @Sendable func finish(success: Bool, state: RuntimeState, detail: String, shouldTerminate: Bool) { + guard completionGate.claim() else { return } + if didAccessKey { + keyURL.stopAccessingSecurityScopedResource() + } + Task { @MainActor in + if self.liveSSHProcess === process { + if shouldTerminate { + process.terminate() + } + if success { + self.runtimeState = state + self.sessionStartedAt = Date() + self.sessionStatusDetail = detail + } else { + self.liveSSHProcess = nil + self.runtimeState = self.activeTarget == nil ? .idle : state + self.sessionStartedAt = nil + self.sessionStatusDetail = detail + } + } + continuation.resume(returning: success) + } + } + + process.terminationHandler = { terminatedProcess in + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrText = String(data: stderrData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let detail = stderrText.isEmpty + ? "SSH session ended before it became active." + : "SSH login failed: \(stderrText)" + finish( + success: false, + state: .failed, + detail: detail, + shouldTerminate: terminatedProcess.isRunning + ) + } + + do { + try process.run() + self.liveSSHProcess = process + } catch { + finish(success: false, state: .failed, detail: "SSH could not be started: \(error.localizedDescription)", shouldTerminate: false) + return + } + + connectionQueue.asyncAfter(deadline: .now() + timeout) { + guard process.isRunning else { return } + finish( + success: true, + state: .active, + detail: "SSH session active for \(targetSummary) using \(target.sshKeyDisplayName.isEmpty ? "the selected key" : target.sshKeyDisplayName).", + shouldTerminate: false + ) + } + } + } + + private func runRemoteBrowseCommandMac( + target: SavedTarget, + path: String, + timeout: TimeInterval + ) async -> RemoteBrowseResult { + guard let bookmarkData = target.sshKeyBookmarkData else { + return .failure("The selected SSH key is no longer available.") + } + guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else { + return .failure("The selected SSH key could not be resolved. Re-select the key file.") + } + + let didAccessKey = keyURL.startAccessingSecurityScopedResource() + defer { + if didAccessKey { + keyURL.stopAccessingSecurityScopedResource() + } + } + + let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)" + let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up))) + let remotePath = normalizedRemoteBrowserPath(path) + let remoteCommand = makeRemoteBrowseCommand(for: remotePath) + + return await withCheckedContinuation { continuation in + let process = Process() + let outputPipe = Pipe() + let errorPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = [ + "-i", keyURL.path, + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "NumberOfPasswordPrompts=0", + "-o", "PreferredAuthentications=publickey", + "-o", "PubkeyAuthentication=yes", + "-o", "ConnectTimeout=\(connectTimeoutSeconds)", + "-p", "\(target.port)", + loginTarget, + remoteCommand + ] + process.standardOutput = outputPipe + process.standardError = errorPipe + + process.terminationHandler = { terminatedProcess in + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let outputText = String(data: outputData, encoding: .utf8) ?? "" + let errorText = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if terminatedProcess.terminationStatus != 0 { + continuation.resume(returning: .failure(errorText.isEmpty ? "Remote file browser failed." : "Remote file browser failed: \(errorText)")) + return + } + + guard let payload = self.parseRemoteBrowsePayload(outputText) else { + continuation.resume(returning: .failure("Remote file browser returned an unreadable listing.")) + return + } + + continuation.resume(returning: .success(payload)) + } + + do { + try process.run() + } catch { + continuation.resume(returning: .failure("SSH browser command could not be started: \(error.localizedDescription)")) + return + } + + connectionQueue.asyncAfter(deadline: .now() + timeout) { + guard process.isRunning else { return } + process.terminate() + } + } + } + + private func runRemoteReadCommandMac( + target: SavedTarget, + path: String, + timeout: TimeInterval + ) async -> RemoteReadResult { + guard let bookmarkData = target.sshKeyBookmarkData else { + return .failure("The selected SSH key is no longer available.") + } + guard let keyURL = resolveSecurityScopedBookmarkMac(bookmarkData) else { + return .failure("The selected SSH key could not be resolved. Re-select the key file.") + } + + let didAccessKey = keyURL.startAccessingSecurityScopedResource() + defer { + if didAccessKey { + keyURL.stopAccessingSecurityScopedResource() + } + } + + let loginTarget = target.username.isEmpty ? target.host : "\(target.username)@\(target.host)" + let connectTimeoutSeconds = max(1, Int(timeout.rounded(.up))) + let remotePath = normalizedRemoteBrowserPath(path) + let remoteCommand = makeRemoteReadCommand(for: remotePath) + let outputMarker = Data("__NVE_REMOTE_FILE__\n".utf8) + + return await withCheckedContinuation { continuation in + let process = Process() + let outputPipe = Pipe() + let errorPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = [ + "-i", keyURL.path, + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "NumberOfPasswordPrompts=0", + "-o", "PreferredAuthentications=publickey", + "-o", "PubkeyAuthentication=yes", + "-o", "ConnectTimeout=\(connectTimeoutSeconds)", + "-p", "\(target.port)", + loginTarget, + remoteCommand + ] + process.standardOutput = outputPipe + process.standardError = errorPipe + + process.terminationHandler = { terminatedProcess in + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorText = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if terminatedProcess.terminationStatus != 0 { + continuation.resume(returning: .failure(errorText.isEmpty ? "Remote file preview failed." : "Remote file preview failed: \(errorText)")) + return + } + + guard outputData.starts(with: outputMarker) else { + continuation.resume(returning: .failure("Remote file preview returned unreadable data.")) + return + } + + let payloadData = outputData.dropFirst(outputMarker.count) + let previewByteLimit = 1_048_576 + if payloadData.count > previewByteLimit { + continuation.resume(returning: .failure("Remote file preview is limited to 1 MB in Phase 7.")) + return + } + + let content = String(decoding: payloadData, as: UTF8.self) + let name = URL(fileURLWithPath: remotePath).lastPathComponent + continuation.resume(returning: .success(RemotePreviewDocument(name: name, path: remotePath, content: content))) + } + + do { + try process.run() + } catch { + continuation.resume(returning: .failure("SSH preview command could not be started: \(error.localizedDescription)")) + return + } + + connectionQueue.asyncAfter(deadline: .now() + timeout) { + guard process.isRunning else { return } + process.terminate() + } + } + } + + private nonisolated func normalizedRemoteBrowserPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "~" : trimmed + } + + private nonisolated func makeRemoteBrowseCommand(for path: String) -> String { + let pathArgument = path == "~" ? "~" : shellQuoted(path) + return "cd -- \(pathArgument) && pwd && printf '__NVE_BROWSER__\\n' && LC_ALL=C /bin/ls -1ApA" + } + + private nonisolated func makeRemoteReadCommand(for path: String) -> String { + let pathArgument = path == "~" ? "~" : shellQuoted(path) + return "printf '__NVE_REMOTE_FILE__\\n' && LC_ALL=C /usr/bin/head -c 1048577 -- \(pathArgument)" + } + + private nonisolated func parseRemoteBrowsePayload(_ output: String) -> RemoteBrowsePayload? { + let separator = "\n__NVE_BROWSER__\n" + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = trimmedOutput.range(of: separator) else { return nil } + let path = String(trimmedOutput[.. RemoteFileEntry? in + let line = String(rawEntry) + guard line != "." && line != ".." else { return nil } + let isDirectory = line.hasSuffix("/") + let displayName = isDirectory ? String(line.dropLast()) : line + let fullPath = path == "/" ? "/\(displayName)" : "\(path)/\(displayName)" + return RemoteFileEntry(name: displayName, path: fullPath, isDirectory: isDirectory) + } + .sorted { + if $0.isDirectory != $1.isDirectory { + return $0.isDirectory && !$1.isDirectory + } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return RemoteBrowsePayload(path: path.isEmpty ? "~" : path, entries: entries) + } + + private nonisolated func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func resolveSecurityScopedBookmarkMac(_ data: Data) -> URL? { + var isStale = false + guard let resolved = try? URL( + resolvingBookmarkData: data, + options: [.withSecurityScope, .withoutUI], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + return nil + } + return resolved + } +#endif } diff --git a/Neon Vision Editor/Data/EditorViewModel.swift b/Neon Vision Editor/Data/EditorViewModel.swift index 47477c2..2461e7e 100644 --- a/Neon Vision Editor/Data/EditorViewModel.swift +++ b/Neon Vision Editor/Data/EditorViewModel.swift @@ -390,6 +390,8 @@ final class TabData: Identifiable { fileprivate(set) var lastKnownFileModificationDate: Date? fileprivate(set) var isLoadingContent: Bool fileprivate(set) var isLargeFileCandidate: Bool + fileprivate(set) var remotePreviewPath: String? + fileprivate(set) var isReadOnlyPreview: Bool init( id: UUID = UUID(), @@ -402,7 +404,9 @@ final class TabData: Identifiable { lastSavedFingerprint: UInt64? = nil, lastKnownFileModificationDate: Date? = nil, isLoadingContent: Bool = false, - isLargeFileCandidate: Bool = false + isLargeFileCandidate: Bool = false, + remotePreviewPath: String? = nil, + isReadOnlyPreview: Bool = false ) { self.id = id self.name = name @@ -415,6 +419,8 @@ final class TabData: Identifiable { self.lastKnownFileModificationDate = lastKnownFileModificationDate self.isLoadingContent = isLoadingContent self.isLargeFileCandidate = isLargeFileCandidate + self.remotePreviewPath = remotePreviewPath + self.isReadOnlyPreview = isReadOnlyPreview } var content: String { contentStorage.string() } @@ -979,6 +985,7 @@ class EditorViewModel { // Tab-scoped content update API that centralizes dirty/idempotence behavior. func updateTabContent(tabID: UUID, content: String) { guard let index = tabIndex(for: tabID) else { return } + guard !tabs[index].isReadOnlyPreview else { return } if tabs[index].isLoadingContent { // During staged file load, content updates are system-driven; do not mark dirty. _ = applyTabCommand( @@ -1019,6 +1026,7 @@ class EditorViewModel { // Incremental piece-table mutation path used by the editor delegates for large content responsiveness. func applyTabContentEdit(tabID: UUID, range: NSRange, replacement: String) { guard let index = tabIndex(for: tabID) else { return } + guard !tabs[index].isReadOnlyPreview else { return } guard !tabs[index].isLoadingContent else { return } let outcome = applyTabCommand( @@ -1068,6 +1076,7 @@ class EditorViewModel { // Saves tab content to the existing file URL or falls back to Save As. func saveFile(tabID: UUID, allowExternalOverwrite: Bool = false) { guard let index = tabIndex(for: tabID) else { return } + guard !tabs[index].isReadOnlyPreview else { return } if !allowExternalOverwrite, let conflict = detectExternalConflict(for: tabs[index]) { pendingExternalFileConflict = conflict @@ -1143,6 +1152,7 @@ class EditorViewModel { // Saves tab content to a user-selected path on macOS. func saveFileAs(tabID: UUID) { guard let index = tabIndex(for: tabID) else { return } + guard !tabs[index].isReadOnlyPreview else { return } #if os(macOS) let panel = NSSavePanel() panel.nameFieldStringValue = tabs[index].name @@ -1322,6 +1332,56 @@ class EditorViewModel { return true } + func openRemotePreviewDocument(name: String, remotePath: String, content: String) { + let trimmedPath = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { return } + + let pseudoURL = URL(fileURLWithPath: trimmedPath) + let detectedLanguage = LanguageDetector.shared.preferredLanguage(for: pseudoURL) + ?? languageMap[pseudoURL.pathExtension.lowercased()] + ?? "plain" + let languageLocked = detectedLanguage != "plain" + let title = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? pseudoURL.lastPathComponent + : name + + if let existingIndex = tabs.firstIndex(where: { $0.remotePreviewPath == trimmedPath }) { + cancelPendingLanguageDetection(for: tabs[existingIndex].id) + tabs[existingIndex].name = title + tabs[existingIndex].fileURL = nil + tabs[existingIndex].language = detectedLanguage + tabs[existingIndex].languageLocked = languageLocked + _ = tabs[existingIndex].replaceContentStorage(with: content, markDirty: false, compareIfLengthAtMost: nil) + tabs[existingIndex].markClean(withFingerprint: nil) + tabs[existingIndex].updateLastKnownFileModificationDate(nil) + tabs[existingIndex].isLoadingContent = false + tabs[existingIndex].isLargeFileCandidate = false + tabs[existingIndex].remotePreviewPath = trimmedPath + tabs[existingIndex].isReadOnlyPreview = true + selectedTabID = tabs[existingIndex].id + recordTabStateMutation(rebuildIndexes: true) + return + } + + let tab = TabData( + name: title, + content: content, + language: detectedLanguage, + fileURL: nil, + languageLocked: languageLocked, + isDirty: false, + lastSavedFingerprint: nil, + lastKnownFileModificationDate: nil, + isLoadingContent: false, + isLargeFileCandidate: false, + remotePreviewPath: trimmedPath, + isReadOnlyPreview: true + ) + tabs.append(tab) + selectedTabID = tab.id + recordTabStateMutation(rebuildIndexes: true) + } + nonisolated static func isSupportedEditorFileURL(_ url: URL) -> Bool { if url.hasDirectoryPath { return false } let fileName = url.lastPathComponent.lowercased() diff --git a/Neon Vision Editor/UI/ContentView+Actions.swift b/Neon Vision Editor/UI/ContentView+Actions.swift index 665c869..3a1d433 100644 --- a/Neon Vision Editor/UI/ContentView+Actions.swift +++ b/Neon Vision Editor/UI/ContentView+Actions.swift @@ -88,6 +88,7 @@ extension ContentView { func saveCurrentTabFromToolbar() { guard let tab = viewModel.selectedTab else { return } + guard !tab.isReadOnlyPreview else { return } #if os(macOS) viewModel.saveFile(tabID: tab.id) #else @@ -106,6 +107,7 @@ extension ContentView { func saveCurrentTabAsFromToolbar() { guard let tab = viewModel.selectedTab else { return } + guard !tab.isReadOnlyPreview else { return } #if os(macOS) viewModel.saveFileAs(tabID: tab.id) #else diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index 22651a8..c892822 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -373,7 +373,7 @@ extension ContentView { Button(action: { saveCurrentTabFromToolbar() }) { Image(systemName: "square.and.arrow.down") } - .disabled(viewModel.selectedTab == nil) + .disabled(viewModel.selectedTab == nil || viewModel.selectedTab?.isReadOnlyPreview == true) .help("Save File (Cmd+S)") .accessibilityLabel("Save file") .accessibilityHint("Saves the current tab") diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index d868bed..088c129 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -3182,6 +3182,7 @@ struct ContentView: View { viewModel.selectedTab?.content ?? singleContent }, set: { newValue in + guard viewModel.selectedTab?.isReadOnlyPreview != true else { return } viewModel.updateTabContent(tabID: selectedID, content: newValue) } ) @@ -3236,6 +3237,10 @@ struct ContentView: View { return currentDocumentUTF16Length >= 300_000 } + private var isSelectedTabReadOnlyPreview: Bool { + viewModel.selectedTab?.isReadOnlyPreview == true + } + private var shouldUseDeferredLargeFileOpenMode: Bool { largeFileOpenModeRaw == "deferred" || largeFileOpenModeRaw == "plainText" } @@ -4206,6 +4211,7 @@ struct ContentView: View { autoCloseBracketsEnabled: autoCloseBracketsEnabled, highlightRefreshToken: highlightRefreshToken, isTabLoadingContent: viewModel.selectedTab?.isLoadingContent ?? false, + isReadOnly: isSelectedTabReadOnlyPreview, onTextMutation: { mutation in viewModel.applyTabContentEdit( tabID: mutation.documentID, @@ -5031,11 +5037,18 @@ struct ContentView: View { Button { viewModel.selectTab(id: tab.id) } label: { - Text(tab.name + (tab.isDirty ? " •" : "")) - .lineLimit(1) - .font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular)) - .padding(.leading, 10) - .padding(.vertical, 6) + HStack(spacing: 6) { + Text(tab.name + (tab.isDirty ? " •" : "")) + .lineLimit(1) + .font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular)) + if tab.isReadOnlyPreview { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .padding(.leading, 10) + .padding(.vertical, 6) } .buttonStyle(.plain) #if os(macOS) diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 6d3158f..d84fd4d 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -1902,6 +1902,7 @@ struct CustomTextEditor: NSViewRepresentable { let autoCloseBracketsEnabled: Bool let highlightRefreshToken: Int let isTabLoadingContent: Bool + let isReadOnly: Bool let onTextMutation: ((EditorTextMutation) -> Void)? private var fontName: String { @@ -2030,7 +2031,7 @@ struct CustomTextEditor: NSViewRepresentable { let textView = AcceptingTextView(frame: .zero) textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView") // Configure editing behavior and visuals - textView.isEditable = true + textView.isEditable = !isReadOnly textView.isRichText = false textView.usesFindBar = !isLargeFileMode textView.usesInspectorBar = false @@ -2156,7 +2157,7 @@ struct CustomTextEditor: NSViewRepresentable { if let textView = nsView.documentView as? NSTextView { var needsLayoutRefresh = false var didChangeRulerConfiguration = false - textView.isEditable = true + textView.isEditable = !isReadOnly textView.isSelectable = true let acceptingView = textView as? AcceptingTextView let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false @@ -2537,7 +2538,7 @@ struct CustomTextEditor: NSViewRepresentable { let remaining = targetLength - location guard remaining > 0 else { self.isInstallingLargeText = false - textView.isEditable = true + textView.isEditable = !parent.isReadOnly let safeLocation = min(max(0, previousSelection.location), targetLength) let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation)) textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength)) @@ -4053,6 +4054,7 @@ struct CustomTextEditor: UIViewRepresentable { let autoCloseBracketsEnabled: Bool let highlightRefreshToken: Int let isTabLoadingContent: Bool + let isReadOnly: Bool let onTextMutation: ((EditorTextMutation) -> Void)? private var fontName: String { @@ -4085,6 +4087,7 @@ struct CustomTextEditor: UIViewRepresentable { let theme = currentEditorTheme(colorScheme: colorScheme) textView.delegate = context.coordinator + textView.isEditable = !isReadOnly let initialFont = resolvedUIFont() textView.font = initialFont let paragraphStyle = NSMutableParagraphStyle() @@ -4155,6 +4158,7 @@ struct CustomTextEditor: UIViewRepresentable { func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) { let textView = uiView.textView context.coordinator.parent = self + textView.isEditable = !isReadOnly let didSwitchDocument = context.coordinator.lastDocumentID != documentID let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision @@ -4406,7 +4410,7 @@ struct CustomTextEditor: UIViewRepresentable { let remaining = targetLength - location guard remaining > 0 else { self.isInstallingLargeText = false - textView.isEditable = true + textView.isEditable = !parent.isReadOnly let safeLocation = min(max(0, previousSelection.location), targetLength) let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation)) textView.selectedRange = NSRange(location: safeLocation, length: safeLength) diff --git a/Neon Vision Editor/UI/NeonSettingsView.swift b/Neon Vision Editor/UI/NeonSettingsView.swift index 196c284..23b5a80 100644 --- a/Neon Vision Editor/UI/NeonSettingsView.swift +++ b/Neon Vision Editor/UI/NeonSettingsView.swift @@ -102,6 +102,12 @@ struct NeonSettingsView: View { @State private var diagnosticsCopyStatus: String = "" @State private var remotePreparationStatus: String = "" @State private var remoteConnectNickname: String = "" + @State private var remotePortDraft: String = "22" + @State private var remoteBrowserPathDraft: String = "~" +#if os(macOS) + @State private var remoteSSHKeyBookmarkData: Data? = nil + @State private var remoteSSHKeyDisplayName: String = "" +#endif @State private var supportRefreshTask: Task? @State private var isDiscoveringFonts: Bool = false private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") @@ -1811,6 +1817,11 @@ struct NeonSettingsView: View { remoteConnectNickname.trimmingCharacters(in: .whitespacesAndNewlines) } + private var sanitizedRemotePort: Int { + let parsedPort = Int(remotePortDraft.trimmingCharacters(in: .whitespacesAndNewlines)) ?? remotePort + return min(max(parsedPort, 1), 65535) + } + private var canSubmitRemoteConnectDraft: Bool { remoteSessionsEnabled && !trimmedRemoteHost.isEmpty } @@ -1820,13 +1831,13 @@ struct NeonSettingsView: View { return "Local workspace only. Remote modules stay inactive until you enable this preview." } if remoteSessionStore.isRemotePreviewConnecting, let activeTarget = remoteSessionStore.activeTarget { - return "Connecting to \(activeTarget.connectionSummary). This TCP handshake is user-triggered and still does not enable file browsing or remote editing." + return "Connecting to \(activeTarget.connectionSummary). This login is user-triggered and still does not enable file browsing or remote editing." } if remoteSessionStore.isRemotePreviewConnected, let activeTarget = remoteSessionStore.activeTarget { - return "Remote session active for \(activeTarget.connectionSummary). Phase 4 keeps the connection explicit and does not enable file browsing or remote editing." + return "Remote session active for \(activeTarget.connectionSummary). Phase 6 adds a read-only file browser on macOS while remote editing stays inactive." } if let activeTarget = remoteSessionStore.activeTarget { - return "Active preview target: \(activeTarget.connectionSummary). Starting a remote session now performs an explicit TCP connection attempt in Phase 4." + return "Active preview target: \(activeTarget.connectionSummary). Starting a remote session now performs an explicit SSH login on macOS when a key is selected, or a TCP connection test otherwise." } if !remoteSessionStore.savedTargets.isEmpty { return "Remote preview is enabled. Choose a saved target or create a new local preview target when ready." @@ -1854,14 +1865,32 @@ struct NeonSettingsView: View { nickname: trimmedRemoteConnectNickname, host: trimmedRemoteHost, username: trimmedRemoteUsername, - port: remotePort + port: sanitizedRemotePort, + sshKeyBookmarkData: { +#if os(macOS) + remoteSSHKeyBookmarkData +#else + nil +#endif + }(), + sshKeyDisplayName: { +#if os(macOS) + remoteSSHKeyDisplayName +#else + "" +#endif + }() ) else { return } remoteSessionsEnabled = true remotePreparedTarget = target.connectionSummary remoteConnectNickname = target.nickname - remotePreparationStatus = "Local preview target selected. No network connection has been opened." + remotePort = target.port + remotePortDraft = String(target.port) + remotePreparationStatus = target.sshKeyBookmarkData == nil + ? "Local preview target selected. No network connection has been opened." + : "SSH target selected. The login remains inactive until you start a session." showRemoteConnectSheet = false } @@ -2046,12 +2075,214 @@ struct NeonSettingsView: View { } } + private func syncRemotePortDraftFromStoredValue() { + remotePortDraft = String(remotePort) + } + + private func applyRemotePortDraft() { + let sanitizedPort = sanitizedRemotePort + remotePort = sanitizedPort + remotePortDraft = String(sanitizedPort) + } + + private func syncRemoteBrowserPathDraft() { + remoteBrowserPathDraft = remoteSessionStore.remoteBrowserPath + } + + private func loadRemoteBrowserPath(_ path: String? = nil) { +#if os(macOS) + Task { + let didLoad = await remoteSessionStore.loadRemoteDirectory(path: path) + await MainActor.run { + syncRemoteBrowserPathDraft() + if didLoad { + remotePreparationStatus = remoteSessionStore.remoteBrowserStatusDetail + } + } + } +#endif + } + + private func browseRemoteParentDirectory() { +#if os(macOS) + let currentPath = remoteSessionStore.remoteBrowserPath + guard currentPath != "/" && currentPath != "~" else { return } + let nsPath = currentPath as NSString + let parentPath = nsPath.deletingLastPathComponent + loadRemoteBrowserPath(parentPath.isEmpty ? "/" : parentPath) +#endif + } + + private func applyRemoteBrowserPathDraft() { + loadRemoteBrowserPath(remoteBrowserPathDraft) + } + +#if os(macOS) + private func openRemotePreviewFile(_ entry: RemoteSessionStore.RemoteFileEntry) { + Task { + guard let document = await remoteSessionStore.openRemoteFilePreview(path: entry.path) else { + await MainActor.run { + remotePreparationStatus = remoteSessionStore.remoteBrowserStatusDetail + } + return + } + await MainActor.run { + guard let activeEditorViewModel = WindowViewModelRegistry.shared.activeViewModel() else { + remotePreparationStatus = "Bring an editor window to the front before opening a remote preview." + return + } + activeEditorViewModel.openRemotePreviewDocument( + name: document.name, + remotePath: document.path, + content: document.content + ) + remotePreparationStatus = "Opened \(document.name) as a read-only remote preview." + } + } + } + + private func syncRemoteSSHKeyDraftFromActiveTarget() { + remoteSSHKeyBookmarkData = remoteSessionStore.activeTarget?.sshKeyBookmarkData + remoteSSHKeyDisplayName = remoteSessionStore.activeTarget?.sshKeyDisplayName ?? "" + } + + private func chooseRemoteSSHKey() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [] + panel.allowsOtherFileTypes = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = true + panel.title = "Select SSH Private Key" + panel.prompt = "Use Key" + + guard panel.runModal() == .OK, let keyURL = panel.url else { return } + + let didAccess = keyURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + keyURL.stopAccessingSecurityScopedResource() + } + } + + guard let bookmarkData = try? keyURL.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) else { + remotePreparationStatus = "The selected SSH key could not be stored securely." + return + } + + remoteSSHKeyBookmarkData = bookmarkData + remoteSSHKeyDisplayName = keyURL.lastPathComponent + } + + private func clearRemoteSSHKeySelection() { + remoteSSHKeyBookmarkData = nil + remoteSSHKeyDisplayName = "" + } +#endif + +#if os(macOS) + private var canShowRemoteBrowser: Bool { + remoteSessionStore.isRemotePreviewConnected && remoteSessionStore.activeTarget?.sshKeyBookmarkData != nil + } + + private var remoteBrowserSection: some View { + VStack(alignment: .leading, spacing: UI.space12) { + HStack(spacing: UI.space12) { + TextField("Remote Path", text: $remoteBrowserPathDraft) + .textFieldStyle(.roundedBorder) + .onSubmit { + applyRemoteBrowserPathDraft() + } + + Button("Refresh") { + loadRemoteBrowserPath(remoteBrowserPathDraft) + } + .buttonStyle(.bordered) + .disabled(remoteSessionStore.isRemoteBrowserLoading) + + Button("Up") { + browseRemoteParentDirectory() + } + .buttonStyle(.bordered) + .disabled(remoteSessionStore.isRemoteBrowserLoading || remoteSessionStore.remoteBrowserPath == "/" || remoteSessionStore.remoteBrowserPath == "~") + } + + Text(remoteSessionStore.remoteBrowserStatusDetail.isEmpty ? "Browse the active remote session read-only. Folders load on demand, and supported text files open as read-only previews." : remoteSessionStore.remoteBrowserStatusDetail) + .font(Typography.footnote) + .foregroundStyle(.secondary) + + if remoteSessionStore.isRemoteBrowserLoading { + ProgressView() + .controlSize(.small) + } + + if remoteSessionStore.remoteBrowserEntries.isEmpty, !remoteSessionStore.remoteBrowserStatusDetail.isEmpty, !remoteSessionStore.isRemoteBrowserLoading { + Text("No remote entries loaded yet.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: UI.space8) { + ForEach(remoteSessionStore.remoteBrowserEntries) { entry in + Button { + if entry.isDirectory { + loadRemoteBrowserPath(entry.path) + } else { + openRemotePreviewFile(entry) + } + } label: { + HStack(spacing: UI.space8) { + Image(systemName: entry.isDirectory ? "folder" : "doc.text") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .foregroundStyle(.primary) + Text(entry.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if entry.isDirectory { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } else { + Text("Open Preview") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(UI.space10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(inputFieldBackground.opacity(0.85)) + ) + } + .buttonStyle(.plain) + .disabled(remoteSessionStore.isRemoteBrowserLoading) + .accessibilityLabel(entry.isDirectory ? "Open remote folder \(entry.name)" : "Open read-only remote preview \(entry.name)") + .accessibilityHint(entry.isDirectory ? "Loads the selected remote folder" : "Opens the selected remote file as a read-only preview tab") + } + } + } + } + .onAppear { + syncRemoteBrowserPathDraft() + } + } +#endif + private var remoteConnectSheet: some View { VStack(alignment: .leading, spacing: UI.space16) { Text("Remote Connect") .font(.title3.weight(.semibold)) - Text("Connect stores and selects a preview target. Phase 4 adds an explicit TCP connection attempt only when you start a session, while file browsing and remote editing stay inactive.") + Text("Connect stores and selects a preview target. On macOS, selecting an SSH key enables an explicit SSH login only when you start a session. Phase 7 adds read-only browsing and read-only file previews after login, while remote editing still stays inactive.") .font(Typography.footnote) .foregroundStyle(.secondary) @@ -2070,10 +2301,44 @@ struct NeonSettingsView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() #endif - Stepper(value: $remotePort, in: 1...65535) { - Text("Port \(remotePort)") - .font(.body.monospacedDigit()) + TextField("Port", text: $remotePortDraft) + .textFieldStyle(.roundedBorder) +#if os(iOS) + .keyboardType(.numberPad) +#endif + .onSubmit { + applyRemotePortDraft() + } + + Text("Port range: 1-65535. Port 22 is the standard SSH port.") + .font(Typography.footnote) + .foregroundStyle(.secondary) + +#if os(macOS) + VStack(alignment: .leading, spacing: UI.space8) { + HStack(spacing: UI.space12) { + Button(remoteSSHKeyBookmarkData == nil ? "Choose SSH Key…" : "Change SSH Key…") { + chooseRemoteSSHKey() + } + .buttonStyle(.bordered) + + if remoteSSHKeyBookmarkData != nil { + Button("Clear Key") { + clearRemoteSSHKeySelection() + } + .buttonStyle(.bordered) + } + } + + Text(remoteSSHKeyDisplayName.isEmpty ? "No SSH key selected. Without a key, Start Session falls back to a TCP connection test." : "Selected key: \(remoteSSHKeyDisplayName)") + .font(Typography.footnote) + .foregroundStyle(.secondary) } +#else + Text("SSH-key login is currently available on macOS only. iPhone and iPad keep using the local prepared-target flow.") + .font(Typography.footnote) + .foregroundStyle(.secondary) +#endif } HStack(spacing: UI.space12) { @@ -2093,6 +2358,12 @@ struct NeonSettingsView: View { } .padding(UI.space20) .frame(minWidth: 320, idealWidth: 420) + .onAppear { + syncRemotePortDraftFromStoredValue() +#if os(macOS) + syncRemoteSSHKeyDraftFromActiveTarget() +#endif + } } private var remoteSection: some View { @@ -2126,12 +2397,12 @@ struct NeonSettingsView: View { } settingsCardSection( - title: "Phase 4 Scope", + title: "Phase 7 Scope", icon: "lock.shield", emphasis: .secondary, showsAccentStripe: false ) { - Text("Phase 4 adds an explicit user-triggered TCP connection attempt with timeout and manual stop. SSH login, remote file browsing, and live remote editing remain inactive.") + Text("Phase 7 adds a macOS-only read-only remote file browser plus read-only remote file previews after SSH-key login. iPhone and iPad continue to use local target preparation only, and remote editing remains inactive.") .font(Typography.footnote) .foregroundStyle(.secondary) } @@ -2150,6 +2421,15 @@ struct NeonSettingsView: View { .font(Typography.footnote) .foregroundStyle(.secondary) +#if os(macOS) + if canShowRemoteBrowser { + GroupBox("Remote Browser") { + remoteBrowserSection + .padding(UI.groupPadding) + } + } +#endif + if !remoteSessionStore.savedTargets.isEmpty { remoteSavedTargetsList } @@ -2163,8 +2443,8 @@ struct NeonSettingsView: View { .padding(UI.groupPadding) } - GroupBox("Phase 4 Scope") { - Text("Phase 4 adds an explicit user-triggered TCP connection attempt with timeout and manual stop. SSH login, remote file browsing, and live remote editing remain inactive.") + GroupBox("Phase 7 Scope") { + Text("Phase 7 adds a macOS-only read-only remote file browser plus read-only remote file previews after SSH-key login. iPhone and iPad continue to use local target preparation only, and remote editing remains inactive.") .font(Typography.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -2178,6 +2458,12 @@ struct NeonSettingsView: View { remotePreparationStatus = "" } } + .onChange(of: remotePort) { _, newValue in + remotePortDraft = String(newValue) + } + .onChange(of: remoteSessionStore.remoteBrowserPath) { _, newValue in + remoteBrowserPathDraft = newValue + } } private var aiSection: some View {