Add phase 7 remote read-only preview flow

This commit is contained in:
h3p 2026-03-29 12:39:47 +02:00
parent 5852207f81
commit 2eb2b43d2d
No known key found for this signature in database
9 changed files with 867 additions and 31 deletions

View file

@ -361,7 +361,7 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 575; CURRENT_PROJECT_VERSION = 577;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U; DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@ -444,7 +444,7 @@
CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 575; CURRENT_PROJECT_VERSION = 577;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U; DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;

View file

@ -58,6 +58,11 @@ struct NeonVisionMacAppCommands: Commands {
activeEditorViewModel().selectedTab != nil 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) { private func post(_ name: Notification.Name, object: Any? = nil) {
postWindowCommand(name, object) postWindowCommand(name, object)
} }
@ -137,7 +142,7 @@ struct NeonVisionMacAppCommands: Commands {
} }
} }
.keyboardShortcut("s", modifiers: .command) .keyboardShortcut("s", modifiers: .command)
.disabled(!hasSelectedTab) .disabled(!hasSavableSelectedTab)
Button("Save As…") { Button("Save As…") {
let current = activeEditorViewModel() let current = activeEditorViewModel()
@ -146,7 +151,7 @@ struct NeonVisionMacAppCommands: Commands {
} }
} }
.keyboardShortcut("s", modifiers: [.command, .shift]) .keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!hasSelectedTab) .disabled(!hasSavableSelectedTab)
Button("Rename") { Button("Rename") {
let current = activeEditorViewModel() let current = activeEditorViewModel()

View file

@ -4,9 +4,9 @@ import Observation
private final class RemoteSessionCompletionGate: @unchecked Sendable { private final class RemoteSessionCompletionGate: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var didComplete = false nonisolated(unsafe) private var didComplete = false
func claim() -> Bool { nonisolated func claim() -> Bool {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
guard !didComplete else { return false } guard !didComplete else { return false }
@ -32,6 +32,8 @@ final class RemoteSessionStore {
var host: String var host: String
var username: String var username: String
var port: Int var port: Int
var sshKeyBookmarkData: Data?
var sshKeyDisplayName: String
var lastPreparedAt: Date var lastPreparedAt: Date
init( init(
@ -40,6 +42,8 @@ final class RemoteSessionStore {
host: String, host: String,
username: String, username: String,
port: Int, port: Int,
sshKeyBookmarkData: Data? = nil,
sshKeyDisplayName: String = "",
lastPreparedAt: Date = Date() lastPreparedAt: Date = Date()
) { ) {
self.id = id self.id = id
@ -47,6 +51,8 @@ final class RemoteSessionStore {
self.host = host self.host = host
self.username = username self.username = username
self.port = port self.port = port
self.sshKeyBookmarkData = sshKeyBookmarkData
self.sshKeyDisplayName = sshKeyDisplayName
self.lastPreparedAt = lastPreparedAt 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() static let shared = RemoteSessionStore()
private static let savedTargetsKey = "RemoteSessionSavedTargetsV1" private static let savedTargetsKey = "RemoteSessionSavedTargetsV1"
@ -74,7 +94,14 @@ final class RemoteSessionStore {
private(set) var runtimeState: RuntimeState = .idle private(set) var runtimeState: RuntimeState = .idle
private(set) var sessionStartedAt: Date? = nil private(set) var sessionStartedAt: Date? = nil
private(set) var sessionStatusDetail: String = "" 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 private var liveConnection: NWConnection? = nil
#if os(macOS)
private var liveSSHProcess: Process? = nil
#endif
private let connectionQueue = DispatchQueue(label: "RemoteSessionStore.Connection") private let connectionQueue = DispatchQueue(label: "RemoteSessionStore.Connection")
private init() { private init() {
@ -98,13 +125,21 @@ final class RemoteSessionStore {
runtimeState == .connecting 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) let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedHost.isEmpty else { return nil } guard !trimmedHost.isEmpty else { return nil }
let sanitizedPort = min(max(port, 1), 65535) let sanitizedPort = min(max(port, 1), 65535)
let normalizedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
let displayNickname = normalizedNickname.isEmpty ? trimmedHost : normalizedNickname let displayNickname = normalizedNickname.isEmpty ? trimmedHost : normalizedNickname
let normalizedSSHKeyDisplayName = sshKeyDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
let target = SavedTarget( let target = SavedTarget(
id: existingTargetID(host: trimmedHost, username: normalizedUsername, port: sanitizedPort) ?? UUID(), id: existingTargetID(host: trimmedHost, username: normalizedUsername, port: sanitizedPort) ?? UUID(),
@ -112,6 +147,8 @@ final class RemoteSessionStore {
host: trimmedHost, host: trimmedHost,
username: normalizedUsername, username: normalizedUsername,
port: sanitizedPort, port: sanitizedPort,
sshKeyBookmarkData: sshKeyBookmarkData,
sshKeyDisplayName: normalizedSSHKeyDisplayName,
lastPreparedAt: Date() lastPreparedAt: Date()
) )
@ -121,6 +158,7 @@ final class RemoteSessionStore {
runtimeState = .ready runtimeState = .ready
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = "" sessionStatusDetail = ""
clearRemoteBrowserState()
persist() persist()
syncLegacyDefaults(with: target) syncLegacyDefaults(with: target)
return target return target
@ -133,6 +171,7 @@ final class RemoteSessionStore {
runtimeState = .idle runtimeState = .idle
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = "" sessionStatusDetail = ""
clearRemoteBrowserState()
persist() persist()
syncLegacyDefaultsForDisconnect() syncLegacyDefaultsForDisconnect()
} }
@ -146,6 +185,7 @@ final class RemoteSessionStore {
runtimeState = .idle runtimeState = .idle
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = "" sessionStatusDetail = ""
clearRemoteBrowserState()
syncLegacyDefaultsForDisconnect() syncLegacyDefaultsForDisconnect()
} }
persist() persist()
@ -158,6 +198,7 @@ final class RemoteSessionStore {
runtimeState = .ready runtimeState = .ready
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = "" sessionStatusDetail = ""
clearRemoteBrowserState()
persist() persist()
syncLegacyDefaults(with: target) syncLegacyDefaults(with: target)
} }
@ -167,6 +208,11 @@ final class RemoteSessionStore {
let targetSummary = target.connectionSummary let targetSummary = target.connectionSummary
cancelLiveConnection() cancelLiveConnection()
#if os(macOS)
if target.sshKeyBookmarkData != nil {
return await startSSHSessionMac(target: target, timeout: timeout)
}
#endif
runtimeState = .connecting runtimeState = .connecting
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = "Opening a TCP connection to \(targetSummary)" sessionStatusDetail = "Opening a TCP connection to \(targetSummary)"
@ -231,8 +277,72 @@ final class RemoteSessionStore {
runtimeState = activeTarget == nil ? .idle : .ready runtimeState = activeTarget == nil ? .idle : .ready
sessionStartedAt = nil sessionStartedAt = nil
sessionStatusDetail = activeTarget == nil ? "" : "Connection closed. The target stays selected for later." 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) { private func upsert(_ target: SavedTarget) {
if let existingIndex = savedTargets.firstIndex(where: { $0.id == target.id }) { if let existingIndex = savedTargets.firstIndex(where: { $0.id == target.id }) {
savedTargets[existingIndex] = target savedTargets[existingIndex] = target
@ -293,5 +403,361 @@ final class RemoteSessionStore {
private func cancelLiveConnection() { private func cancelLiveConnection() {
liveConnection?.cancel() liveConnection?.cancel()
liveConnection = nil 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[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
let listing = String(trimmedOutput[range.upperBound...])
let entries = listing
.split(separator: "\n", omittingEmptySubsequences: true)
.compactMap { rawEntry -> 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
} }

View file

@ -390,6 +390,8 @@ final class TabData: Identifiable {
fileprivate(set) var lastKnownFileModificationDate: Date? fileprivate(set) var lastKnownFileModificationDate: Date?
fileprivate(set) var isLoadingContent: Bool fileprivate(set) var isLoadingContent: Bool
fileprivate(set) var isLargeFileCandidate: Bool fileprivate(set) var isLargeFileCandidate: Bool
fileprivate(set) var remotePreviewPath: String?
fileprivate(set) var isReadOnlyPreview: Bool
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@ -402,7 +404,9 @@ final class TabData: Identifiable {
lastSavedFingerprint: UInt64? = nil, lastSavedFingerprint: UInt64? = nil,
lastKnownFileModificationDate: Date? = nil, lastKnownFileModificationDate: Date? = nil,
isLoadingContent: Bool = false, isLoadingContent: Bool = false,
isLargeFileCandidate: Bool = false isLargeFileCandidate: Bool = false,
remotePreviewPath: String? = nil,
isReadOnlyPreview: Bool = false
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@ -415,6 +419,8 @@ final class TabData: Identifiable {
self.lastKnownFileModificationDate = lastKnownFileModificationDate self.lastKnownFileModificationDate = lastKnownFileModificationDate
self.isLoadingContent = isLoadingContent self.isLoadingContent = isLoadingContent
self.isLargeFileCandidate = isLargeFileCandidate self.isLargeFileCandidate = isLargeFileCandidate
self.remotePreviewPath = remotePreviewPath
self.isReadOnlyPreview = isReadOnlyPreview
} }
var content: String { contentStorage.string() } var content: String { contentStorage.string() }
@ -979,6 +985,7 @@ class EditorViewModel {
// Tab-scoped content update API that centralizes dirty/idempotence behavior. // Tab-scoped content update API that centralizes dirty/idempotence behavior.
func updateTabContent(tabID: UUID, content: String) { func updateTabContent(tabID: UUID, content: String) {
guard let index = tabIndex(for: tabID) else { return } guard let index = tabIndex(for: tabID) else { return }
guard !tabs[index].isReadOnlyPreview else { return }
if tabs[index].isLoadingContent { if tabs[index].isLoadingContent {
// During staged file load, content updates are system-driven; do not mark dirty. // During staged file load, content updates are system-driven; do not mark dirty.
_ = applyTabCommand( _ = applyTabCommand(
@ -1019,6 +1026,7 @@ class EditorViewModel {
// Incremental piece-table mutation path used by the editor delegates for large content responsiveness. // Incremental piece-table mutation path used by the editor delegates for large content responsiveness.
func applyTabContentEdit(tabID: UUID, range: NSRange, replacement: String) { func applyTabContentEdit(tabID: UUID, range: NSRange, replacement: String) {
guard let index = tabIndex(for: tabID) else { return } guard let index = tabIndex(for: tabID) else { return }
guard !tabs[index].isReadOnlyPreview else { return }
guard !tabs[index].isLoadingContent else { return } guard !tabs[index].isLoadingContent else { return }
let outcome = applyTabCommand( let outcome = applyTabCommand(
@ -1068,6 +1076,7 @@ class EditorViewModel {
// Saves tab content to the existing file URL or falls back to Save As. // Saves tab content to the existing file URL or falls back to Save As.
func saveFile(tabID: UUID, allowExternalOverwrite: Bool = false) { func saveFile(tabID: UUID, allowExternalOverwrite: Bool = false) {
guard let index = tabIndex(for: tabID) else { return } guard let index = tabIndex(for: tabID) else { return }
guard !tabs[index].isReadOnlyPreview else { return }
if !allowExternalOverwrite, if !allowExternalOverwrite,
let conflict = detectExternalConflict(for: tabs[index]) { let conflict = detectExternalConflict(for: tabs[index]) {
pendingExternalFileConflict = conflict pendingExternalFileConflict = conflict
@ -1143,6 +1152,7 @@ class EditorViewModel {
// Saves tab content to a user-selected path on macOS. // Saves tab content to a user-selected path on macOS.
func saveFileAs(tabID: UUID) { func saveFileAs(tabID: UUID) {
guard let index = tabIndex(for: tabID) else { return } guard let index = tabIndex(for: tabID) else { return }
guard !tabs[index].isReadOnlyPreview else { return }
#if os(macOS) #if os(macOS)
let panel = NSSavePanel() let panel = NSSavePanel()
panel.nameFieldStringValue = tabs[index].name panel.nameFieldStringValue = tabs[index].name
@ -1322,6 +1332,56 @@ class EditorViewModel {
return true 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 { nonisolated static func isSupportedEditorFileURL(_ url: URL) -> Bool {
if url.hasDirectoryPath { return false } if url.hasDirectoryPath { return false }
let fileName = url.lastPathComponent.lowercased() let fileName = url.lastPathComponent.lowercased()

View file

@ -88,6 +88,7 @@ extension ContentView {
func saveCurrentTabFromToolbar() { func saveCurrentTabFromToolbar() {
guard let tab = viewModel.selectedTab else { return } guard let tab = viewModel.selectedTab else { return }
guard !tab.isReadOnlyPreview else { return }
#if os(macOS) #if os(macOS)
viewModel.saveFile(tabID: tab.id) viewModel.saveFile(tabID: tab.id)
#else #else
@ -106,6 +107,7 @@ extension ContentView {
func saveCurrentTabAsFromToolbar() { func saveCurrentTabAsFromToolbar() {
guard let tab = viewModel.selectedTab else { return } guard let tab = viewModel.selectedTab else { return }
guard !tab.isReadOnlyPreview else { return }
#if os(macOS) #if os(macOS)
viewModel.saveFileAs(tabID: tab.id) viewModel.saveFileAs(tabID: tab.id)
#else #else

View file

@ -373,7 +373,7 @@ extension ContentView {
Button(action: { saveCurrentTabFromToolbar() }) { Button(action: { saveCurrentTabFromToolbar() }) {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
} }
.disabled(viewModel.selectedTab == nil) .disabled(viewModel.selectedTab == nil || viewModel.selectedTab?.isReadOnlyPreview == true)
.help("Save File (Cmd+S)") .help("Save File (Cmd+S)")
.accessibilityLabel("Save file") .accessibilityLabel("Save file")
.accessibilityHint("Saves the current tab") .accessibilityHint("Saves the current tab")

View file

@ -3182,6 +3182,7 @@ struct ContentView: View {
viewModel.selectedTab?.content ?? singleContent viewModel.selectedTab?.content ?? singleContent
}, },
set: { newValue in set: { newValue in
guard viewModel.selectedTab?.isReadOnlyPreview != true else { return }
viewModel.updateTabContent(tabID: selectedID, content: newValue) viewModel.updateTabContent(tabID: selectedID, content: newValue)
} }
) )
@ -3236,6 +3237,10 @@ struct ContentView: View {
return currentDocumentUTF16Length >= 300_000 return currentDocumentUTF16Length >= 300_000
} }
private var isSelectedTabReadOnlyPreview: Bool {
viewModel.selectedTab?.isReadOnlyPreview == true
}
private var shouldUseDeferredLargeFileOpenMode: Bool { private var shouldUseDeferredLargeFileOpenMode: Bool {
largeFileOpenModeRaw == "deferred" || largeFileOpenModeRaw == "plainText" largeFileOpenModeRaw == "deferred" || largeFileOpenModeRaw == "plainText"
} }
@ -4206,6 +4211,7 @@ struct ContentView: View {
autoCloseBracketsEnabled: autoCloseBracketsEnabled, autoCloseBracketsEnabled: autoCloseBracketsEnabled,
highlightRefreshToken: highlightRefreshToken, highlightRefreshToken: highlightRefreshToken,
isTabLoadingContent: viewModel.selectedTab?.isLoadingContent ?? false, isTabLoadingContent: viewModel.selectedTab?.isLoadingContent ?? false,
isReadOnly: isSelectedTabReadOnlyPreview,
onTextMutation: { mutation in onTextMutation: { mutation in
viewModel.applyTabContentEdit( viewModel.applyTabContentEdit(
tabID: mutation.documentID, tabID: mutation.documentID,
@ -5031,11 +5037,18 @@ struct ContentView: View {
Button { Button {
viewModel.selectTab(id: tab.id) viewModel.selectTab(id: tab.id)
} label: { } label: {
Text(tab.name + (tab.isDirty ? "" : "")) HStack(spacing: 6) {
.lineLimit(1) Text(tab.name + (tab.isDirty ? "" : ""))
.font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular)) .lineLimit(1)
.padding(.leading, 10) .font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular))
.padding(.vertical, 6) if tab.isReadOnlyPreview {
Image(systemName: "lock.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.padding(.leading, 10)
.padding(.vertical, 6)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#if os(macOS) #if os(macOS)

View file

@ -1902,6 +1902,7 @@ struct CustomTextEditor: NSViewRepresentable {
let autoCloseBracketsEnabled: Bool let autoCloseBracketsEnabled: Bool
let highlightRefreshToken: Int let highlightRefreshToken: Int
let isTabLoadingContent: Bool let isTabLoadingContent: Bool
let isReadOnly: Bool
let onTextMutation: ((EditorTextMutation) -> Void)? let onTextMutation: ((EditorTextMutation) -> Void)?
private var fontName: String { private var fontName: String {
@ -2030,7 +2031,7 @@ struct CustomTextEditor: NSViewRepresentable {
let textView = AcceptingTextView(frame: .zero) let textView = AcceptingTextView(frame: .zero)
textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView") textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView")
// Configure editing behavior and visuals // Configure editing behavior and visuals
textView.isEditable = true textView.isEditable = !isReadOnly
textView.isRichText = false textView.isRichText = false
textView.usesFindBar = !isLargeFileMode textView.usesFindBar = !isLargeFileMode
textView.usesInspectorBar = false textView.usesInspectorBar = false
@ -2156,7 +2157,7 @@ struct CustomTextEditor: NSViewRepresentable {
if let textView = nsView.documentView as? NSTextView { if let textView = nsView.documentView as? NSTextView {
var needsLayoutRefresh = false var needsLayoutRefresh = false
var didChangeRulerConfiguration = false var didChangeRulerConfiguration = false
textView.isEditable = true textView.isEditable = !isReadOnly
textView.isSelectable = true textView.isSelectable = true
let acceptingView = textView as? AcceptingTextView let acceptingView = textView as? AcceptingTextView
let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false
@ -2537,7 +2538,7 @@ struct CustomTextEditor: NSViewRepresentable {
let remaining = targetLength - location let remaining = targetLength - location
guard remaining > 0 else { guard remaining > 0 else {
self.isInstallingLargeText = false self.isInstallingLargeText = false
textView.isEditable = true textView.isEditable = !parent.isReadOnly
let safeLocation = min(max(0, previousSelection.location), targetLength) let safeLocation = min(max(0, previousSelection.location), targetLength)
let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation)) let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation))
textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength)) textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength))
@ -4053,6 +4054,7 @@ struct CustomTextEditor: UIViewRepresentable {
let autoCloseBracketsEnabled: Bool let autoCloseBracketsEnabled: Bool
let highlightRefreshToken: Int let highlightRefreshToken: Int
let isTabLoadingContent: Bool let isTabLoadingContent: Bool
let isReadOnly: Bool
let onTextMutation: ((EditorTextMutation) -> Void)? let onTextMutation: ((EditorTextMutation) -> Void)?
private var fontName: String { private var fontName: String {
@ -4085,6 +4087,7 @@ struct CustomTextEditor: UIViewRepresentable {
let theme = currentEditorTheme(colorScheme: colorScheme) let theme = currentEditorTheme(colorScheme: colorScheme)
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.isEditable = !isReadOnly
let initialFont = resolvedUIFont() let initialFont = resolvedUIFont()
textView.font = initialFont textView.font = initialFont
let paragraphStyle = NSMutableParagraphStyle() let paragraphStyle = NSMutableParagraphStyle()
@ -4155,6 +4158,7 @@ struct CustomTextEditor: UIViewRepresentable {
func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) { func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) {
let textView = uiView.textView let textView = uiView.textView
context.coordinator.parent = self context.coordinator.parent = self
textView.isEditable = !isReadOnly
let didSwitchDocument = context.coordinator.lastDocumentID != documentID let didSwitchDocument = context.coordinator.lastDocumentID != documentID
let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent let didFinishTabLoad = (context.coordinator.lastTabLoadingContent == true) && !isTabLoadingContent
let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision let didReceiveExternalEdit = context.coordinator.lastExternalEditRevision != externalEditRevision
@ -4406,7 +4410,7 @@ struct CustomTextEditor: UIViewRepresentable {
let remaining = targetLength - location let remaining = targetLength - location
guard remaining > 0 else { guard remaining > 0 else {
self.isInstallingLargeText = false self.isInstallingLargeText = false
textView.isEditable = true textView.isEditable = !parent.isReadOnly
let safeLocation = min(max(0, previousSelection.location), targetLength) let safeLocation = min(max(0, previousSelection.location), targetLength)
let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation)) let safeLength = min(max(0, previousSelection.length), max(0, targetLength - safeLocation))
textView.selectedRange = NSRange(location: safeLocation, length: safeLength) textView.selectedRange = NSRange(location: safeLocation, length: safeLength)

View file

@ -102,6 +102,12 @@ struct NeonSettingsView: View {
@State private var diagnosticsCopyStatus: String = "" @State private var diagnosticsCopyStatus: String = ""
@State private var remotePreparationStatus: String = "" @State private var remotePreparationStatus: String = ""
@State private var remoteConnectNickname: 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<Void, Never>? @State private var supportRefreshTask: Task<Void, Never>?
@State private var isDiscoveringFonts: Bool = false @State private var isDiscoveringFonts: Bool = false
private let privacyPolicyURL = URL(string: "https://github.com/h3pdesign/Neon-Vision-Editor/blob/main/PRIVACY.md") 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) 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 { private var canSubmitRemoteConnectDraft: Bool {
remoteSessionsEnabled && !trimmedRemoteHost.isEmpty remoteSessionsEnabled && !trimmedRemoteHost.isEmpty
} }
@ -1820,13 +1831,13 @@ struct NeonSettingsView: View {
return "Local workspace only. Remote modules stay inactive until you enable this preview." return "Local workspace only. Remote modules stay inactive until you enable this preview."
} }
if remoteSessionStore.isRemotePreviewConnecting, let activeTarget = remoteSessionStore.activeTarget { 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 { 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 { 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 { if !remoteSessionStore.savedTargets.isEmpty {
return "Remote preview is enabled. Choose a saved target or create a new local preview target when ready." 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, nickname: trimmedRemoteConnectNickname,
host: trimmedRemoteHost, host: trimmedRemoteHost,
username: trimmedRemoteUsername, username: trimmedRemoteUsername,
port: remotePort port: sanitizedRemotePort,
sshKeyBookmarkData: {
#if os(macOS)
remoteSSHKeyBookmarkData
#else
nil
#endif
}(),
sshKeyDisplayName: {
#if os(macOS)
remoteSSHKeyDisplayName
#else
""
#endif
}()
) else { ) else {
return return
} }
remoteSessionsEnabled = true remoteSessionsEnabled = true
remotePreparedTarget = target.connectionSummary remotePreparedTarget = target.connectionSummary
remoteConnectNickname = target.nickname 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 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 { private var remoteConnectSheet: some View {
VStack(alignment: .leading, spacing: UI.space16) { VStack(alignment: .leading, spacing: UI.space16) {
Text("Remote Connect") Text("Remote Connect")
.font(.title3.weight(.semibold)) .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) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -2070,10 +2301,44 @@ struct NeonSettingsView: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
#endif #endif
Stepper(value: $remotePort, in: 1...65535) { TextField("Port", text: $remotePortDraft)
Text("Port \(remotePort)") .textFieldStyle(.roundedBorder)
.font(.body.monospacedDigit()) #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) { HStack(spacing: UI.space12) {
@ -2093,6 +2358,12 @@ struct NeonSettingsView: View {
} }
.padding(UI.space20) .padding(UI.space20)
.frame(minWidth: 320, idealWidth: 420) .frame(minWidth: 320, idealWidth: 420)
.onAppear {
syncRemotePortDraftFromStoredValue()
#if os(macOS)
syncRemoteSSHKeyDraftFromActiveTarget()
#endif
}
} }
private var remoteSection: some View { private var remoteSection: some View {
@ -2126,12 +2397,12 @@ struct NeonSettingsView: View {
} }
settingsCardSection( settingsCardSection(
title: "Phase 4 Scope", title: "Phase 7 Scope",
icon: "lock.shield", icon: "lock.shield",
emphasis: .secondary, emphasis: .secondary,
showsAccentStripe: false 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) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -2150,6 +2421,15 @@ struct NeonSettingsView: View {
.font(Typography.footnote) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#if os(macOS)
if canShowRemoteBrowser {
GroupBox("Remote Browser") {
remoteBrowserSection
.padding(UI.groupPadding)
}
}
#endif
if !remoteSessionStore.savedTargets.isEmpty { if !remoteSessionStore.savedTargets.isEmpty {
remoteSavedTargetsList remoteSavedTargetsList
} }
@ -2163,8 +2443,8 @@ struct NeonSettingsView: View {
.padding(UI.groupPadding) .padding(UI.groupPadding)
} }
GroupBox("Phase 4 Scope") { GroupBox("Phase 7 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.") 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) .font(Typography.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -2178,6 +2458,12 @@ struct NeonSettingsView: View {
remotePreparationStatus = "" remotePreparationStatus = ""
} }
} }
.onChange(of: remotePort) { _, newValue in
remotePortDraft = String(newValue)
}
.onChange(of: remoteSessionStore.remoteBrowserPath) { _, newValue in
remoteBrowserPathDraft = newValue
}
} }
private var aiSection: some View { private var aiSection: some View {