mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Release v0.6.0 polish and workflow completion
This commit is contained in:
parent
c150a8bdea
commit
a05d33b048
16 changed files with 2928 additions and 370 deletions
54
CHANGELOG.md
54
CHANGELOG.md
|
|
@ -6,20 +6,58 @@ The format follows *Keep a Changelog*. Versions use semantic versioning with pre
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Highlights
|
||||
- Added a macOS-only opt-in remote preview flow that progresses from prepared targets to explicit SSH-key session startup.
|
||||
- Added a macOS-only read-only remote file browser for active SSH-key sessions.
|
||||
- Added read-only remote file previews that open into locked editor tabs without enabling remote save or live remote editing.
|
||||
### Breaking changes
|
||||
- None.
|
||||
|
||||
### Security
|
||||
- Local SSH commit verification can now use the repo-scoped `.git_allowed_signers` file.
|
||||
- Remote session startup remains fully user-triggered, with strict host-key checking and no background polling.
|
||||
### Migration
|
||||
- None.
|
||||
|
||||
## [v0.6.0] - 2026-03-30
|
||||
|
||||
### Why Upgrade
|
||||
- Remote workflows are clearer on every active surface, with better tab/session state, safer conflict recovery, and more complete iPhone/iPad remote-session support.
|
||||
- Search, `Find in Files`, and `Find & Replace` are much more mature across macOS, iPhone, and iPad, with stronger keyboard flow, clearer match visibility, better sizing, and cleaner panel layouts.
|
||||
- Markdown Preview is more polished on all platforms with stronger live-preview readability, full-window themed preview rendering, and clearer export/share feedback.
|
||||
- iPad editor chrome is more consistent, including tighter toolbar overflow behavior and better default sizing for the project-structure sidebar.
|
||||
- German localization is more complete, especially in Settings and the recently polished search/preview surfaces.
|
||||
|
||||
### Highlights
|
||||
- Completed the `0.6.0` remote-workflow line with clearer remote tab/document/session state, broker failure clarity, explicit compare-before-reload conflict handling, and safer unsupported-file handling in the remote browser.
|
||||
- Expanded search and navigation maturity with stronger `Quick Open` ranking, clearer search-source/status messaging, grouped `Find in Files` results, direct toolbar entry points, and improved Return/selection behavior.
|
||||
- Added more cross-platform keyboard parity on iPad, including sidebar shortcuts, Settings tab navigation, and result-list arrow-key movement in search panels.
|
||||
- Polished Markdown Preview with clearer export affordances, full-window live preview rendering, larger preview typography, and lightweight copy/export status messaging.
|
||||
- Continued cross-platform UI refinement for `Find & Replace`, `Find in Files`, project sidebar defaults, toolbar overflow placement, and theme selection visibility.
|
||||
|
||||
### Fixes
|
||||
- Fixed Settings tab selection unexpectedly jumping back to `General`.
|
||||
- Fixed German localization gaps in Settings `General`, remote flows, search panels, and Markdown Preview controls.
|
||||
- Fixed theme selection and editor-selection contrast, including aligned selection color behavior for `Neon Glow` and stronger selection emphasis across built-in themes.
|
||||
- Fixed `Find & Replace` and `Find in Files` usability regressions across macOS, iPhone, and iPad, including close behavior, live match visibility, result counts, clear actions, panel sizing, button readability, and dark-mode contrast.
|
||||
- Fixed iPad toolbar crowding by moving `Close All Tabs` into the overflow menu and tightening overflow placement inside the glass toolbar.
|
||||
- Fixed iPad project-structure sidebar default width so the localized title no longer clips on first presentation.
|
||||
|
||||
### Breaking changes
|
||||
- None.
|
||||
|
||||
### Migration
|
||||
- For local SSH commit verification, point Git at `.git_allowed_signers` if your clone does not already set `gpg.ssh.allowedSignersFile`.
|
||||
- None.
|
||||
|
||||
## [v0.5.9] - 2026-03-30
|
||||
|
||||
### Why Upgrade
|
||||
- iPhone project-sidebar controls are visible again after the duplicate title/header was removed.
|
||||
|
||||
### Highlights
|
||||
- Added a small iOS hotfix release for the project-sidebar header regression introduced during the `0.5.8` sidebar cleanup.
|
||||
|
||||
### Fixes
|
||||
- Fixed iPhone project-sidebar header actions so `Open Folder`, `Open File`, refresh, and the sidebar menu remain visible even when the duplicate inline `Project Structure` title is hidden.
|
||||
|
||||
### Breaking changes
|
||||
- None.
|
||||
|
||||
### Migration
|
||||
- None.
|
||||
|
||||
## [v0.5.8] - 2026-03-28
|
||||
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 584;
|
||||
CURRENT_PROJECT_VERSION = 587;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -407,7 +407,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 584;
|
||||
CURRENT_PROJECT_VERSION = 587;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -490,7 +490,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 0.5.8;
|
||||
MARKETING_VERSION = 0.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
|||
|
|
@ -56,6 +56,16 @@ private struct RemoteBrokerTransportError: LocalizedError {
|
|||
private let remoteDocumentByteLimit = 1_048_576
|
||||
private let brokerMessageByteLimit = 1_310_720
|
||||
|
||||
private func makeRemoteSessionFileEntry(name: String, path: String, isDirectory: Bool) -> RemoteSessionStore.RemoteFileEntry {
|
||||
let isSupportedTextFile = isDirectory || EditorViewModel.isSupportedEditorFileURL(URL(fileURLWithPath: path))
|
||||
return RemoteSessionStore.RemoteFileEntry(
|
||||
name: name,
|
||||
path: path,
|
||||
isDirectory: isDirectory,
|
||||
isSupportedTextFile: isSupportedTextFile
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class RemoteSessionStore {
|
||||
|
|
@ -113,6 +123,7 @@ final class RemoteSessionStore {
|
|||
let name: String
|
||||
let path: String
|
||||
let isDirectory: Bool
|
||||
let isSupportedTextFile: Bool
|
||||
|
||||
var id: String { path }
|
||||
}
|
||||
|
|
@ -458,7 +469,7 @@ final class RemoteSessionStore {
|
|||
case .failure(let error):
|
||||
runtimeState = .failed
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = error.localizedDescription
|
||||
sessionStatusDetail = makeBrokerRecoveryDetail(for: error.localizedDescription)
|
||||
persist()
|
||||
return false
|
||||
}
|
||||
|
|
@ -509,13 +520,13 @@ final class RemoteSessionStore {
|
|||
case .success(let response):
|
||||
remoteBrowserPath = response.path ?? requestedPath
|
||||
remoteBrowserEntries = response.entries?.map {
|
||||
RemoteFileEntry(name: $0.name, path: $0.path, isDirectory: $0.isDirectory)
|
||||
makeRemoteSessionFileEntry(name: $0.name, path: $0.path, isDirectory: $0.isDirectory)
|
||||
} ?? []
|
||||
remoteBrowserStatusDetail = response.detail
|
||||
return response.success
|
||||
case .failure(let error):
|
||||
remoteBrowserEntries = []
|
||||
remoteBrowserStatusDetail = error.localizedDescription
|
||||
noteBrokerRecoveryNeeded(error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -541,13 +552,13 @@ final class RemoteSessionStore {
|
|||
case .success(let response):
|
||||
remoteBrowserPath = response.path ?? requestedPath
|
||||
remoteBrowserEntries = response.entries?.map {
|
||||
RemoteFileEntry(name: $0.name, path: $0.path, isDirectory: $0.isDirectory)
|
||||
makeRemoteSessionFileEntry(name: $0.name, path: $0.path, isDirectory: $0.isDirectory)
|
||||
} ?? []
|
||||
remoteBrowserStatusDetail = response.detail
|
||||
return response.success
|
||||
case .failure(let error):
|
||||
remoteBrowserEntries = []
|
||||
remoteBrowserStatusDetail = error.localizedDescription
|
||||
noteBrokerRecoveryNeeded(error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -625,7 +636,7 @@ final class RemoteSessionStore {
|
|||
revisionToken: response.revision
|
||||
)
|
||||
case .failure(let error):
|
||||
remoteBrowserStatusDetail = error.localizedDescription
|
||||
noteBrokerRecoveryNeeded(error.localizedDescription)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -709,7 +720,7 @@ final class RemoteSessionStore {
|
|||
hasConflict: !response.success && response.detail.localizedCaseInsensitiveContains("changed remotely")
|
||||
)
|
||||
case .failure(let error):
|
||||
remoteBrowserStatusDetail = error.localizedDescription
|
||||
noteBrokerRecoveryNeeded(error.localizedDescription)
|
||||
return RemoteSaveResult(
|
||||
didSave: false,
|
||||
detail: remoteBrowserStatusDetail,
|
||||
|
|
@ -881,6 +892,30 @@ final class RemoteSessionStore {
|
|||
isRemoteBrowserLoading = false
|
||||
}
|
||||
|
||||
private func noteBrokerRecoveryNeeded(_ detail: String) {
|
||||
let recoveryDetail = makeBrokerRecoveryDetail(for: detail)
|
||||
runtimeState = .failed
|
||||
sessionStartedAt = nil
|
||||
sessionStatusDetail = recoveryDetail
|
||||
remoteBrowserStatusDetail = recoveryDetail
|
||||
isRemoteBrowserLoading = false
|
||||
persist()
|
||||
}
|
||||
|
||||
private func makeBrokerRecoveryDetail(for detail: String) -> String {
|
||||
let normalized = detail.localizedLowercase
|
||||
if normalized.contains("changed remotely") {
|
||||
return detail
|
||||
}
|
||||
if attachedBrokerDescriptor != nil {
|
||||
return "\(detail) Reattach this device from Settings > Remote using the active Mac attach code."
|
||||
}
|
||||
if brokerSessionDescriptor != nil {
|
||||
return "\(detail) Restart the Mac-hosted SSH session before attaching remote clients again."
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
private func sendBrokerRequest(
|
||||
host: String,
|
||||
port: Int,
|
||||
|
|
@ -1698,7 +1733,13 @@ final class RemoteSessionStore {
|
|||
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)
|
||||
let isSupportedTextFile = isDirectory || EditorViewModel.isSupportedEditorFileURL(URL(fileURLWithPath: fullPath))
|
||||
return RemoteFileEntry(
|
||||
name: displayName,
|
||||
path: fullPath,
|
||||
isDirectory: isDirectory,
|
||||
isSupportedTextFile: isSupportedTextFile
|
||||
)
|
||||
}
|
||||
.sorted {
|
||||
if $0.isDirectory != $1.isDirectory {
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ final class TabData: Identifiable {
|
|||
|
||||
var content: String { contentStorage.string() }
|
||||
var contentUTF16Length: Int { contentStorage.utf16Length }
|
||||
var isRemoteDocument: Bool { remotePreviewPath != nil }
|
||||
|
||||
@discardableResult
|
||||
func replaceContentStorage(
|
||||
|
|
@ -497,11 +498,31 @@ class EditorViewModel {
|
|||
let diskModifiedAt: Date?
|
||||
}
|
||||
|
||||
struct RemoteSaveIssueState: Sendable {
|
||||
let tabID: UUID
|
||||
let remotePath: String
|
||||
let detail: String
|
||||
let isConflict: Bool
|
||||
let requiresReconnect: Bool
|
||||
|
||||
var recoveryGuidance: String {
|
||||
guard requiresReconnect else { return detail }
|
||||
return "\(detail) Detach this device from the broker, then attach again from Settings > Remote using the current Mac attach code."
|
||||
}
|
||||
}
|
||||
|
||||
struct ExternalFileComparisonSnapshot: Sendable {
|
||||
let fileName: String
|
||||
let localContent: String
|
||||
let diskContent: String
|
||||
}
|
||||
|
||||
struct RemoteConflictComparisonSnapshot: Sendable {
|
||||
let tabID: UUID
|
||||
let fileName: String
|
||||
let localContent: String
|
||||
let remoteContent: String
|
||||
}
|
||||
private actor TabCommandQueue {
|
||||
private var isLocked = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
|
@ -534,6 +555,7 @@ class EditorViewModel {
|
|||
private(set) var tabs: [TabData] = []
|
||||
private(set) var selectedTabID: UUID?
|
||||
var pendingExternalFileConflict: ExternalFileConflictState?
|
||||
var pendingRemoteSaveIssue: RemoteSaveIssueState?
|
||||
var showSidebar: Bool = true
|
||||
var isBrainDumpMode: Bool = false
|
||||
var showingRename: Bool = false
|
||||
|
|
@ -1132,6 +1154,52 @@ class EditorViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
func dismissRemoteSaveIssue() {
|
||||
pendingRemoteSaveIssue = nil
|
||||
}
|
||||
|
||||
func detachRemoteBrokerAfterSaveIssue() {
|
||||
pendingRemoteSaveIssue = nil
|
||||
RemoteSessionStore.shared.detachBrokerClient()
|
||||
}
|
||||
|
||||
func retryRemoteSave(tabID: UUID) {
|
||||
pendingRemoteSaveIssue = nil
|
||||
saveFile(tabID: tabID)
|
||||
}
|
||||
|
||||
func reloadRemoteDocumentAfterConflict(tabID: UUID) {
|
||||
guard let index = tabIndex(for: tabID),
|
||||
let remotePath = tabs[index].remotePreviewPath else {
|
||||
pendingRemoteSaveIssue = nil
|
||||
return
|
||||
}
|
||||
|
||||
pendingRemoteSaveIssue = nil
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let document = await RemoteSessionStore.shared.openRemoteDocument(path: remotePath) else {
|
||||
self.pendingRemoteSaveIssue = RemoteSaveIssueState(
|
||||
tabID: tabID,
|
||||
remotePath: remotePath,
|
||||
detail: RemoteSessionStore.shared.remoteBrowserStatusDetail,
|
||||
isConflict: false,
|
||||
requiresReconnect: true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
self.openRemoteDocument(
|
||||
name: document.name,
|
||||
remotePath: document.path,
|
||||
content: document.content,
|
||||
isReadOnly: document.isReadOnly,
|
||||
revisionToken: document.revisionToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func externalConflictComparisonSnapshot(tabID: UUID) async -> ExternalFileComparisonSnapshot? {
|
||||
guard let index = tabIndex(for: tabID),
|
||||
let url = tabs[index].fileURL else { return nil }
|
||||
|
|
@ -1160,6 +1228,24 @@ class EditorViewModel {
|
|||
pendingExternalFileConflict = detectExternalConflict(for: tabs[index])
|
||||
}
|
||||
|
||||
func remoteConflictComparisonSnapshot(tabID: UUID) async -> RemoteConflictComparisonSnapshot? {
|
||||
guard let index = tabIndex(for: tabID),
|
||||
let remotePath = tabs[index].remotePreviewPath else { return nil }
|
||||
let fileName = tabs[index].name
|
||||
let localContent = tabs[index].content
|
||||
|
||||
guard let document = await RemoteSessionStore.shared.openRemoteDocument(path: remotePath) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return RemoteConflictComparisonSnapshot(
|
||||
tabID: tabID,
|
||||
fileName: fileName,
|
||||
localContent: localContent,
|
||||
remoteContent: document.content
|
||||
)
|
||||
}
|
||||
|
||||
// Saves tab content to a user-selected path on macOS.
|
||||
func saveFileAs(tabID: UUID) {
|
||||
guard let index = tabIndex(for: tabID) else { return }
|
||||
|
|
@ -1308,6 +1394,13 @@ class EditorViewModel {
|
|||
)
|
||||
|
||||
guard saveResult.didSave else {
|
||||
self.pendingRemoteSaveIssue = RemoteSaveIssueState(
|
||||
tabID: tabID,
|
||||
remotePath: remotePath,
|
||||
detail: saveResult.detail,
|
||||
isConflict: saveResult.hasConflict,
|
||||
requiresReconnect: self.remoteSaveLikelyNeedsReconnect(saveResult.detail)
|
||||
)
|
||||
self.debugLog(saveResult.detail)
|
||||
return
|
||||
}
|
||||
|
|
@ -1326,9 +1419,20 @@ class EditorViewModel {
|
|||
)
|
||||
)
|
||||
self.tabs[postflightIndex].updateRemoteRevisionToken(saveResult.revisionToken)
|
||||
self.pendingRemoteSaveIssue = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteSaveLikelyNeedsReconnect(_ detail: String) -> Bool {
|
||||
let normalized = detail.localizedLowercase
|
||||
return normalized.contains("waiting for broker")
|
||||
|| normalized.contains("broker attach failed")
|
||||
|| normalized.contains("broker connection cancelled")
|
||||
|| normalized.contains("broker request timed out")
|
||||
|| normalized.contains("no active ssh target")
|
||||
|| normalized.contains("attach to an active mac broker session")
|
||||
}
|
||||
|
||||
private func detectExternalConflict(for tab: TabData) -> ExternalFileConflictState? {
|
||||
guard tab.isDirty, let fileURL = tab.fileURL else { return nil }
|
||||
guard let diskModifiedAt = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else {
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@ extension ContentView {
|
|||
if let match = forwardMatch ?? wrapMatch {
|
||||
tv.setSelectedRange(match.range)
|
||||
tv.scrollRangeToVisible(match.range)
|
||||
tv.showFindIndicator(for: match.range)
|
||||
} else {
|
||||
findStatusMessage = "No matches found"
|
||||
NSSound.beep()
|
||||
|
|
@ -386,6 +387,7 @@ extension ContentView {
|
|||
if let range = ns.range(of: findQuery, options: opts, range: forwardRange).toOptional() ?? ns.range(of: findQuery, options: opts, range: wrapRange).toOptional() {
|
||||
tv.setSelectedRange(range)
|
||||
tv.scrollRangeToVisible(range)
|
||||
tv.showFindIndicator(for: range)
|
||||
} else {
|
||||
findStatusMessage = "No matches found"
|
||||
NSSound.beep()
|
||||
|
|
@ -417,17 +419,166 @@ extension ContentView {
|
|||
}
|
||||
|
||||
iOSFindCursorLocation = next.nextCursorLocation
|
||||
NotificationCenter.default.post(
|
||||
name: .moveCursorToRange,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
EditorCommandUserInfo.rangeLocation: next.range.location,
|
||||
EditorCommandUserInfo.rangeLength: next.range.length
|
||||
]
|
||||
)
|
||||
postEditorRangeSelection(next.range, focusEditor: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
func jumpToCurrentFindMatch() {
|
||||
if findMatchCount == 0 {
|
||||
refreshFindPreview()
|
||||
return
|
||||
}
|
||||
previewFindMatchSelection(forceFromStart: false, shouldFocusEditor: true)
|
||||
}
|
||||
|
||||
func refreshFindPreview() {
|
||||
refreshFindMatchCount()
|
||||
guard !findQuery.isEmpty else { return }
|
||||
guard findMatchCount > 0 else { return }
|
||||
previewFindMatchSelection(forceFromStart: true, shouldFocusEditor: false)
|
||||
}
|
||||
|
||||
func refreshFindMatchCount() {
|
||||
guard !findQuery.isEmpty else {
|
||||
findMatchCount = 0
|
||||
findStatusMessage = ""
|
||||
#if !os(macOS)
|
||||
iOSFindCursorLocation = 0
|
||||
iOSLastFindFingerprint = ""
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let source = currentContentBinding.wrappedValue
|
||||
if findUsesRegex,
|
||||
(try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive])) == nil {
|
||||
findMatchCount = 0
|
||||
findStatusMessage = "Invalid regex pattern"
|
||||
return
|
||||
}
|
||||
|
||||
let count = countFindMatches(in: source)
|
||||
findMatchCount = count
|
||||
if count == 0 {
|
||||
findStatusMessage = "No matches found"
|
||||
} else if findStatusMessage == "No matches found" || findStatusMessage == "Invalid regex pattern" {
|
||||
findStatusMessage = ""
|
||||
}
|
||||
}
|
||||
|
||||
private func previewFindMatchSelection(forceFromStart: Bool, shouldFocusEditor: Bool) {
|
||||
guard !findQuery.isEmpty else { return }
|
||||
let source = currentContentBinding.wrappedValue
|
||||
guard let range = firstFindPreviewRange(in: source, forceFromStart: forceFromStart) else { return }
|
||||
|
||||
#if os(macOS)
|
||||
guard let tv = activeEditorTextView() else { return }
|
||||
if shouldFocusEditor, let win = tv.window {
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
if shouldFocusEditor {
|
||||
tv.window?.makeFirstResponder(tv)
|
||||
}
|
||||
tv.setSelectedRange(range)
|
||||
tv.scrollRangeToVisible(range)
|
||||
tv.showFindIndicator(for: range)
|
||||
#else
|
||||
iOSFindCursorLocation = range.upperBound
|
||||
iOSLastFindFingerprint = "\(findQuery)|\(findUsesRegex)|\(findCaseSensitive)"
|
||||
postEditorRangeSelection(range, focusEditor: shouldFocusEditor)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private func postEditorRangeSelection(_ range: NSRange, focusEditor: Bool) {
|
||||
let userInfo: [String: Any] = [
|
||||
EditorCommandUserInfo.rangeLocation: range.location,
|
||||
EditorCommandUserInfo.rangeLength: range.length,
|
||||
EditorCommandUserInfo.focusEditor: focusEditor
|
||||
]
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .moveCursorToRange, object: nil, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func firstFindPreviewRange(in source: String, forceFromStart: Bool) -> NSRange? {
|
||||
let nsSource = source as NSString
|
||||
guard nsSource.length > 0 else { return nil }
|
||||
|
||||
#if os(macOS)
|
||||
if !forceFromStart,
|
||||
let tv = activeEditorTextView() {
|
||||
let selected = tv.selectedRange()
|
||||
if selected.length > 0,
|
||||
selected.length <= nsSource.length,
|
||||
selected.location >= 0,
|
||||
selected.location + selected.length <= nsSource.length,
|
||||
selectedRangeMatchesFindQuery(selected, in: source) {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if findUsesRegex {
|
||||
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
return regex.firstMatch(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))?.range
|
||||
}
|
||||
|
||||
let options: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
|
||||
return nsSource.range(of: findQuery, options: options, range: NSRange(location: 0, length: nsSource.length)).toOptional()
|
||||
}
|
||||
|
||||
private func selectedRangeMatchesFindQuery(_ range: NSRange, in source: String) -> Bool {
|
||||
guard range.length > 0 else { return false }
|
||||
let nsSource = source as NSString
|
||||
guard range.location >= 0, range.location + range.length <= nsSource.length else { return false }
|
||||
let selectedText = nsSource.substring(with: range)
|
||||
|
||||
if findUsesRegex {
|
||||
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
|
||||
return false
|
||||
}
|
||||
let fullRange = NSRange(location: 0, length: (selectedText as NSString).length)
|
||||
guard let match = regex.firstMatch(in: selectedText, options: [], range: fullRange) else {
|
||||
return false
|
||||
}
|
||||
return match.range.location == 0 && match.range.length == fullRange.length
|
||||
}
|
||||
|
||||
if findCaseSensitive {
|
||||
return selectedText == findQuery
|
||||
}
|
||||
return selectedText.compare(findQuery, options: [.caseInsensitive]) == .orderedSame
|
||||
}
|
||||
|
||||
private func countFindMatches(in source: String) -> Int {
|
||||
let nsSource = source as NSString
|
||||
guard nsSource.length > 0 else { return 0 }
|
||||
|
||||
if findUsesRegex {
|
||||
guard let regex = try? NSRegularExpression(pattern: findQuery, options: findCaseSensitive ? [] : [.caseInsensitive]) else {
|
||||
return 0
|
||||
}
|
||||
return regex.numberOfMatches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
|
||||
}
|
||||
|
||||
let options: NSString.CompareOptions = findCaseSensitive ? [] : [.caseInsensitive]
|
||||
var count = 0
|
||||
var searchRange = NSRange(location: 0, length: nsSource.length)
|
||||
while searchRange.length > 0 {
|
||||
let found = nsSource.range(of: findQuery, options: options, range: searchRange)
|
||||
guard let safeRange = found.toOptional() else { break }
|
||||
count += 1
|
||||
let nextLocation = safeRange.location + max(safeRange.length, 1)
|
||||
if nextLocation >= nsSource.length { break }
|
||||
searchRange = NSRange(location: nextLocation, length: nsSource.length - nextLocation)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func replaceSelection() {
|
||||
#if os(macOS)
|
||||
guard let tv = activeEditorTextView() else { return }
|
||||
|
|
|
|||
|
|
@ -98,10 +98,22 @@ extension ContentView {
|
|||
let filename = suggestedMarkdownPDFFilename()
|
||||
#if os(macOS)
|
||||
try saveMarkdownPreviewPDFOnMac(pdfData, suggestedFilename: filename)
|
||||
showMarkdownPreviewActionStatus(
|
||||
String(
|
||||
format: NSLocalizedString("Markdown Preview Exported PDF: %@", comment: ""),
|
||||
filename
|
||||
)
|
||||
)
|
||||
#else
|
||||
markdownPDFExportDocument = PDFExportDocument(data: pdfData)
|
||||
markdownPDFExportFilename = filename
|
||||
showMarkdownPDFExporter = true
|
||||
showMarkdownPreviewActionStatus(
|
||||
String(
|
||||
format: NSLocalizedString("Markdown Preview Ready PDF: %@", comment: ""),
|
||||
filename
|
||||
)
|
||||
)
|
||||
#endif
|
||||
} catch {
|
||||
markdownPDFExportErrorMessage = error.localizedDescription
|
||||
|
|
@ -149,6 +161,19 @@ extension ContentView {
|
|||
markdownPreviewExportHTML(from: currentContent, mode: markdownPDFExportMode)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func showMarkdownPreviewActionStatus(_ message: String, duration: TimeInterval = 2.0) {
|
||||
let token = UUID()
|
||||
markdownPreviewActionStatusToken = token
|
||||
markdownPreviewActionStatusMessage = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||
Task { @MainActor in
|
||||
guard markdownPreviewActionStatusToken == token else { return }
|
||||
markdownPreviewActionStatusMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func copyMarkdownPreviewHTML() {
|
||||
#if os(macOS)
|
||||
|
|
@ -158,6 +183,7 @@ extension ContentView {
|
|||
UIPasteboard.general.setValue(markdownPreviewShareHTML, forPasteboardType: UTType.html.identifier)
|
||||
UIPasteboard.general.string = markdownPreviewShareHTML
|
||||
#endif
|
||||
showMarkdownPreviewActionStatus(NSLocalizedString("Markdown Preview Copied HTML", comment: ""))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -168,6 +194,7 @@ extension ContentView {
|
|||
#elseif os(iOS)
|
||||
UIPasteboard.general.string = currentContent
|
||||
#endif
|
||||
showMarkdownPreviewActionStatus(NSLocalizedString("Markdown Preview Copied Markdown", comment: ""))
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
|
@ -202,6 +229,7 @@ extension ContentView {
|
|||
backgroundStyle: markdownPreviewBackgroundStyle,
|
||||
translucentBackgroundEnabled: enableTranslucentWindow
|
||||
))
|
||||
\(markdownPreviewRuntimePreviewScaleCSS())
|
||||
</style>
|
||||
</head>
|
||||
<body class="\(markdownPreviewTemplate)">
|
||||
|
|
@ -213,6 +241,48 @@ extension ContentView {
|
|||
"""
|
||||
}
|
||||
|
||||
func markdownPreviewRuntimePreviewScaleCSS() -> String {
|
||||
let previewLayoutCSS = """
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--md-content-background);
|
||||
}
|
||||
.content {
|
||||
max-width: none !important;
|
||||
min-height: 100vh;
|
||||
margin: 0 !important;
|
||||
padding: clamp(14px, 2.2vw, 24px);
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
"""
|
||||
#if os(iOS)
|
||||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
return """
|
||||
\(previewLayoutCSS)
|
||||
html {
|
||||
-webkit-text-size-adjust: \(isPad ? "144%" : "118%");
|
||||
}
|
||||
body {
|
||||
font-size: \(isPad ? "1.24em" : "1.1em");
|
||||
}
|
||||
"""
|
||||
#else
|
||||
return """
|
||||
\(previewLayoutCSS)
|
||||
body {
|
||||
font-size: 1.08em;
|
||||
}
|
||||
"""
|
||||
#endif
|
||||
}
|
||||
|
||||
func markdownPreviewExportHTML(from markdownText: String, mode: MarkdownPDFExportMode) -> String {
|
||||
let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: false)
|
||||
let modeClass = mode == .onePageFit ? " pdf-one-page" : ""
|
||||
|
|
|
|||
|
|
@ -102,8 +102,8 @@ extension ContentView {
|
|||
|
||||
private var iPhonePromotedActionsCount: Int {
|
||||
switch iPhoneToolbarWidth {
|
||||
case 430...: return 4
|
||||
case 395...: return 3
|
||||
case 430...: return 5
|
||||
case 395...: return 4
|
||||
default: return 1
|
||||
}
|
||||
}
|
||||
|
|
@ -137,6 +137,7 @@ extension ContentView {
|
|||
case toggleSidebar
|
||||
case toggleProjectSidebar
|
||||
case findReplace
|
||||
case findInFiles
|
||||
case settings
|
||||
case codeCompletion
|
||||
case performanceMode
|
||||
|
|
@ -163,6 +164,7 @@ extension ContentView {
|
|||
.toggleSidebar,
|
||||
.toggleProjectSidebar,
|
||||
.findReplace,
|
||||
.findInFiles,
|
||||
.settings,
|
||||
.codeCompletion,
|
||||
.lineWrap,
|
||||
|
|
@ -196,6 +198,7 @@ extension ContentView {
|
|||
|
||||
private var iPadPinnedOverflowActions: Set<IPadToolbarAction> {
|
||||
[
|
||||
.closeAllTabs,
|
||||
.performanceMode,
|
||||
.brainDump,
|
||||
.welcomeTour,
|
||||
|
|
@ -204,7 +207,7 @@ extension ContentView {
|
|||
}
|
||||
|
||||
private var iPadAlwaysVisibleActions: [IPadToolbarAction] {
|
||||
[.openFile, .newTab, .closeAllTabs, .saveFile, .findReplace, .settings]
|
||||
[.openFile, .newTab, .saveFile, .findReplace, .findInFiles, .settings]
|
||||
}
|
||||
|
||||
private var iPadPromotedActionSlotCount: Int {
|
||||
|
|
@ -433,6 +436,17 @@ extension ContentView {
|
|||
.keyboardShortcut("f", modifiers: .command)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var findInFilesControl: some View {
|
||||
Button(action: { showFindInFiles = true }) {
|
||||
Image(systemName: "text.magnifyingglass")
|
||||
}
|
||||
.help("Find in Files (Cmd+Shift+F)")
|
||||
.accessibilityLabel("Find in Files")
|
||||
.accessibilityHint("Searches across files in the current project")
|
||||
.keyboardShortcut("f", modifiers: [.command, .shift])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var brainDumpControl: some View {
|
||||
Button(action: {
|
||||
|
|
@ -542,6 +556,7 @@ extension ContentView {
|
|||
case .toggleSidebar: toggleSidebarControl
|
||||
case .toggleProjectSidebar: toggleProjectSidebarControl
|
||||
case .findReplace: findReplaceControl
|
||||
case .findInFiles: findInFilesControl
|
||||
case .settings: settingsControl
|
||||
case .codeCompletion: codeCompletionControl
|
||||
case .performanceMode: performanceModeControl
|
||||
|
|
@ -617,6 +632,10 @@ extension ContentView {
|
|||
Button(action: { showFindReplace = true }) {
|
||||
Label("Find & Replace", systemImage: "magnifyingglass")
|
||||
}
|
||||
case .findInFiles:
|
||||
Button(action: { showFindInFiles = true }) {
|
||||
Label("Find in Files…", systemImage: "text.magnifyingglass")
|
||||
}
|
||||
case .settings:
|
||||
Button(action: { openSettings() }) {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
|
|
@ -687,8 +706,11 @@ extension ContentView {
|
|||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.frame(width: 40, height: 40, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.help("More Actions")
|
||||
.frame(minWidth: 40, minHeight: 40)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -770,6 +792,11 @@ extension ContentView {
|
|||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
|
||||
Button(action: { showFindInFiles = true }) {
|
||||
Label("Find in Files…", systemImage: "text.magnifyingglass")
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.command, .shift])
|
||||
|
||||
Button(action: { viewModel.isLineWrapEnabled.toggle() }) {
|
||||
Label("Enable Wrap / Disable Wrap", systemImage: "text.justify")
|
||||
}
|
||||
|
|
@ -838,6 +865,7 @@ extension ContentView {
|
|||
if iPhonePromotedActionsCount >= 2 { newTabControl }
|
||||
if iPhonePromotedActionsCount >= 3 { saveFileControl }
|
||||
if iPhonePromotedActionsCount >= 4 { findReplaceControl }
|
||||
if iPhonePromotedActionsCount >= 5 { findInFilesControl }
|
||||
keyboardAccessoryControl
|
||||
iOSVerticalSurfaceDivider
|
||||
moreActionsControl
|
||||
|
|
@ -1034,6 +1062,14 @@ extension ContentView {
|
|||
}
|
||||
.help("Find & Replace (Cmd+F)")
|
||||
|
||||
Button(action: {
|
||||
showFindInFiles = true
|
||||
}) {
|
||||
Label("Find in Files", systemImage: "text.magnifyingglass")
|
||||
.foregroundStyle(macToolbarSymbolColor)
|
||||
}
|
||||
.help("Find in Files (Cmd+Shift+F)")
|
||||
|
||||
Button(action: {
|
||||
openSettings()
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -268,6 +268,7 @@ struct ContentView: View {
|
|||
@State var findUsesRegex: Bool = false
|
||||
@State var findCaseSensitive: Bool = false
|
||||
@State var findStatusMessage: String = ""
|
||||
@State var findMatchCount: Int = 0
|
||||
@State var iOSFindCursorLocation: Int = 0
|
||||
@State var iOSLastFindFingerprint: String = ""
|
||||
@State var showProjectStructureSidebar: Bool = false
|
||||
|
|
@ -285,8 +286,11 @@ struct ContentView: View {
|
|||
@State var showUnsavedCloseDialog: Bool = false
|
||||
@State var showCloseAllTabsDialog: Bool = false
|
||||
@State private var showExternalConflictDialog: Bool = false
|
||||
@State private var showRemoteSaveIssueDialog: Bool = false
|
||||
@State private var showExternalConflictCompareSheet: Bool = false
|
||||
@State private var externalConflictCompareSnapshot: EditorViewModel.ExternalFileComparisonSnapshot?
|
||||
@State private var showRemoteConflictCompareSheet: Bool = false
|
||||
@State private var remoteConflictCompareSnapshot: EditorViewModel.RemoteConflictComparisonSnapshot?
|
||||
@State var showClearEditorConfirmDialog: Bool = false
|
||||
@State var showIOSFileImporter: Bool = false
|
||||
@State var showIOSFileExporter: Bool = false
|
||||
|
|
@ -299,6 +303,8 @@ struct ContentView: View {
|
|||
@State var markdownPDFExportDocument: PDFExportDocument = PDFExportDocument()
|
||||
@State var markdownPDFExportFilename: String = "Markdown-Preview.pdf"
|
||||
@State var markdownPDFExportErrorMessage: String?
|
||||
@State var markdownPreviewActionStatusMessage: String = ""
|
||||
@State var markdownPreviewActionStatusToken: UUID = UUID()
|
||||
@State var showQuickSwitcher: Bool = false
|
||||
@State var quickSwitcherQuery: String = ""
|
||||
@State var quickSwitcherProjectFileURLs: [URL] = []
|
||||
|
|
@ -317,6 +323,7 @@ struct ContentView: View {
|
|||
@State var findInFilesCaseSensitive: Bool = false
|
||||
@State var findInFilesResults: [FindInFilesMatch] = []
|
||||
@State var findInFilesStatusMessage: String = ""
|
||||
@State var findInFilesSourceMessage: String = ""
|
||||
@State private var findInFilesTask: Task<Void, Never>?
|
||||
@State private var statusWordCount: Int = 0
|
||||
@State private var statusLineCount: Int = 1
|
||||
|
|
@ -1550,6 +1557,16 @@ struct ContentView: View {
|
|||
showExternalConflictDialog = true
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.pendingRemoteSaveIssue?.tabID) { _, issueTabID in
|
||||
if issueTabID != nil {
|
||||
showRemoteSaveIssueDialog = true
|
||||
}
|
||||
}
|
||||
.onChange(of: showRemoteSaveIssueDialog) { _, isPresented in
|
||||
if !isPresented, viewModel.pendingRemoteSaveIssue != nil {
|
||||
viewModel.dismissRemoteSaveIssue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePastedTextNotification(_ notif: Notification) {
|
||||
|
|
@ -2031,6 +2048,33 @@ struct ContentView: View {
|
|||
}
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
.background(
|
||||
FindReplaceWindowPresenter(
|
||||
isPresented: $showFindReplace,
|
||||
findQuery: $findQuery,
|
||||
replaceQuery: $replaceQuery,
|
||||
useRegex: $findUsesRegex,
|
||||
caseSensitive: $findCaseSensitive,
|
||||
matchCount: $findMatchCount,
|
||||
statusMessage: $findStatusMessage,
|
||||
onPreviewChanged: { refreshFindPreview() },
|
||||
onFindNext: {
|
||||
findNext()
|
||||
refreshFindMatchCount()
|
||||
},
|
||||
onJumpToMatch: { jumpToCurrentFindMatch() },
|
||||
onReplace: {
|
||||
replaceSelection()
|
||||
refreshFindPreview()
|
||||
},
|
||||
onReplaceAll: {
|
||||
replaceAll()
|
||||
refreshFindPreview()
|
||||
},
|
||||
onClose: { showFindReplace = false }
|
||||
)
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
.onDisappear {
|
||||
handleWindowDisappear()
|
||||
}
|
||||
|
|
@ -2091,7 +2135,9 @@ struct ContentView: View {
|
|||
onQuickOpen: {
|
||||
quickSwitcherQuery = ""
|
||||
showQuickSwitcher = true
|
||||
}
|
||||
},
|
||||
onToggleSidebar: { toggleSidebarFromToolbar() },
|
||||
onToggleProjectSidebar: { toggleProjectSidebarFromToolbar() }
|
||||
)
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
|
|
@ -2235,6 +2281,11 @@ struct ContentView: View {
|
|||
// Start with sidebars collapsed only once; otherwise toggles can get reset on layout transitions.
|
||||
viewModel.showSidebar = false
|
||||
showProjectStructureSidebar = false
|
||||
#if os(iOS)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad && abs(projectSidebarWidth - 260) < 0.5 {
|
||||
projectSidebarWidth = 292
|
||||
}
|
||||
#endif
|
||||
didRunInitialWindowLayoutSetup = true
|
||||
}
|
||||
|
||||
|
|
@ -2308,21 +2359,41 @@ struct ContentView: View {
|
|||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
#if !os(macOS)
|
||||
.sheet(isPresented: contentView.$showFindReplace) {
|
||||
FindReplacePanel(
|
||||
findQuery: contentView.$findQuery,
|
||||
replaceQuery: contentView.$replaceQuery,
|
||||
useRegex: contentView.$findUsesRegex,
|
||||
caseSensitive: contentView.$findCaseSensitive,
|
||||
matchCount: contentView.$findMatchCount,
|
||||
statusMessage: contentView.$findStatusMessage,
|
||||
onFindNext: { contentView.findNext() },
|
||||
onReplace: { contentView.replaceSelection() },
|
||||
onReplaceAll: { contentView.replaceAll() }
|
||||
onPreviewChanged: { contentView.refreshFindPreview() },
|
||||
onFindNext: {
|
||||
contentView.findNext()
|
||||
contentView.refreshFindMatchCount()
|
||||
},
|
||||
onJumpToMatch: { contentView.jumpToCurrentFindMatch() },
|
||||
onReplace: {
|
||||
contentView.replaceSelection()
|
||||
contentView.refreshFindPreview()
|
||||
},
|
||||
onReplaceAll: {
|
||||
contentView.replaceAll()
|
||||
contentView.refreshFindPreview()
|
||||
},
|
||||
onClose: { contentView.showFindReplace = false }
|
||||
)
|
||||
#if canImport(UIKit)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(
|
||||
maxWidth: UIDevice.current.userInterfaceIdiom == .phone ? .infinity : 460
|
||||
)
|
||||
#if os(iOS)
|
||||
.presentationDetents([.height(280), .medium])
|
||||
.presentationDetents(
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
? [.height(448), .medium]
|
||||
: [.height(560)]
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationContentInteraction(.scrolls)
|
||||
#endif
|
||||
|
|
@ -2330,6 +2401,7 @@ struct ContentView: View {
|
|||
.frame(width: 420)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
.sheet(isPresented: contentView.$showSettingsSheet) {
|
||||
ConfiguredSettingsView(
|
||||
|
|
@ -2354,10 +2426,10 @@ struct ContentView: View {
|
|||
language: contentView.currentLanguage,
|
||||
translucentBackgroundEnabled: false
|
||||
)
|
||||
.navigationTitle("Sidebar")
|
||||
.navigationTitle(Text(NSLocalizedString("Sidebar", comment: "")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
Button(NSLocalizedString("Done", comment: "")) {
|
||||
contentView.$showCompactSidebarSheet.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
|
@ -2380,10 +2452,10 @@ struct ContentView: View {
|
|||
onOpenProjectFile: { contentView.openProjectFile(url: $0) },
|
||||
onRefreshTree: { contentView.refreshProjectBrowserState() }
|
||||
)
|
||||
.navigationTitle("Project Structure")
|
||||
.navigationTitle(Text(NSLocalizedString("Project Structure", comment: "")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
Button(NSLocalizedString("Done", comment: "")) {
|
||||
contentView.$showCompactProjectSidebarSheet.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
|
@ -2394,11 +2466,11 @@ struct ContentView: View {
|
|||
.sheet(isPresented: contentView.markdownPreviewSheetPresentationBinding) {
|
||||
NavigationStack {
|
||||
contentView.markdownPreviewPane
|
||||
.navigationTitle("Markdown Preview")
|
||||
.navigationTitle(Text(NSLocalizedString("Markdown Preview", comment: "")))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
Button(NSLocalizedString("Done", comment: "")) {
|
||||
contentView.showMarkdownPreviewPane = false
|
||||
}
|
||||
}
|
||||
|
|
@ -2424,6 +2496,7 @@ struct ContentView: View {
|
|||
QuickFileSwitcherPanel(
|
||||
query: contentView.$quickSwitcherQuery,
|
||||
items: contentView.quickSwitcherItems,
|
||||
statusMessage: contentView.quickSwitcherStatusMessage,
|
||||
onSelect: { contentView.selectQuickSwitcherItem($0) },
|
||||
onTogglePin: { contentView.toggleQuickSwitcherPin($0) }
|
||||
)
|
||||
|
|
@ -2437,9 +2510,21 @@ struct ContentView: View {
|
|||
caseSensitive: contentView.$findInFilesCaseSensitive,
|
||||
results: contentView.findInFilesResults,
|
||||
statusMessage: contentView.findInFilesStatusMessage,
|
||||
sourceMessage: contentView.findInFilesSourceMessage,
|
||||
onSearch: { contentView.startFindInFiles() },
|
||||
onSelect: { contentView.selectFindInFilesMatch($0) }
|
||||
onClear: { contentView.clearFindInFiles() },
|
||||
onSelect: { contentView.selectFindInFilesMatch($0) },
|
||||
onClose: { contentView.showFindInFiles = false }
|
||||
)
|
||||
#if os(iOS)
|
||||
.presentationDetents(
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
? [.height(540), .medium]
|
||||
: [.height(700), .large]
|
||||
)
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationContentInteraction(.scrolls)
|
||||
#endif
|
||||
}
|
||||
.sheet(isPresented: contentView.$showLanguageSetupPrompt) {
|
||||
contentView.languageSetupSheet
|
||||
|
|
@ -2536,6 +2621,50 @@ struct ContentView: View {
|
|||
Text("The file changed on disk while you had unsaved edits.")
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
contentView.viewModel.pendingRemoteSaveIssue?.isConflict == true
|
||||
? "Remote file changed"
|
||||
: (contentView.viewModel.pendingRemoteSaveIssue?.requiresReconnect == true
|
||||
? "Remote session unavailable"
|
||||
: "Remote save failed"),
|
||||
isPresented: contentView.$showRemoteSaveIssueDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let issue = contentView.viewModel.pendingRemoteSaveIssue {
|
||||
if issue.isConflict {
|
||||
Button("Compare") {
|
||||
Task {
|
||||
if let snapshot = await contentView.viewModel.remoteConflictComparisonSnapshot(tabID: issue.tabID) {
|
||||
await MainActor.run {
|
||||
contentView.remoteConflictCompareSnapshot = snapshot
|
||||
contentView.showRemoteConflictCompareSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Reload from Remote", role: .destructive) {
|
||||
contentView.viewModel.reloadRemoteDocumentAfterConflict(tabID: issue.tabID)
|
||||
}
|
||||
} else if issue.requiresReconnect {
|
||||
Button("Detach Broker", role: .destructive) {
|
||||
contentView.viewModel.detachRemoteBrokerAfterSaveIssue()
|
||||
}
|
||||
} else {
|
||||
Button("Try Save Again") {
|
||||
contentView.viewModel.retryRemoteSave(tabID: issue.tabID)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Dismiss", role: .cancel) {
|
||||
contentView.viewModel.dismissRemoteSaveIssue()
|
||||
}
|
||||
} message: {
|
||||
if let issue = contentView.viewModel.pendingRemoteSaveIssue {
|
||||
Text(issue.requiresReconnect ? issue.recoveryGuidance : issue.detail)
|
||||
} else {
|
||||
Text("The remote document could not be saved.")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: contentView.$showExternalConflictCompareSheet, onDismiss: {
|
||||
contentView.externalConflictCompareSnapshot = nil
|
||||
}) {
|
||||
|
|
@ -2582,6 +2711,47 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: contentView.$showRemoteConflictCompareSheet, onDismiss: {
|
||||
contentView.remoteConflictCompareSnapshot = nil
|
||||
}) {
|
||||
if let snapshot = contentView.remoteConflictCompareSnapshot {
|
||||
NavigationStack {
|
||||
VStack(spacing: 12) {
|
||||
Text("Compare Local vs Remote: \(snapshot.fileName)")
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Local")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: .constant(snapshot.localContent))
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.disabled(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Remote")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: .constant(snapshot.remoteContent))
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
HStack {
|
||||
Button("Reload from Remote", role: .destructive) {
|
||||
contentView.viewModel.reloadRemoteDocumentAfterConflict(tabID: snapshot.tabID)
|
||||
contentView.showRemoteConflictCompareSheet = false
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") {
|
||||
contentView.showRemoteConflictCompareSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.navigationTitle("Remote Conflict")
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear editor content?", isPresented: contentView.$showClearEditorConfirmDialog, titleVisibility: .visible) {
|
||||
Button("Clear", role: .destructive) { contentView.clearEditorContent() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
|
|
@ -2853,7 +3023,8 @@ struct ContentView: View {
|
|||
guard let location = sessionCaretByFileURL[selectedURL.absoluteString], location >= 0 else { return }
|
||||
var userInfo: [String: Any] = [
|
||||
EditorCommandUserInfo.rangeLocation: location,
|
||||
EditorCommandUserInfo.rangeLength: 0
|
||||
EditorCommandUserInfo.rangeLength: 0,
|
||||
EditorCommandUserInfo.focusEditor: true
|
||||
]
|
||||
#if os(macOS)
|
||||
if let hostWindowNumber {
|
||||
|
|
@ -3265,6 +3436,15 @@ struct ContentView: View {
|
|||
|
||||
private var remoteSessionStatusBadgeText: String {
|
||||
guard remoteSessionsEnabled else { return "" }
|
||||
if remoteSessionStore.runtimeState == .failed, remoteSessionStore.isBrokerClientAttached {
|
||||
return "Local Workspace • Remote Broker Lost"
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed, remoteSessionStore.hasBrokerSession {
|
||||
return "Local Workspace • Remote Broker Failed"
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed {
|
||||
return "Local Workspace • Remote Failed"
|
||||
}
|
||||
if remoteSessionStore.isBrokerClientAttached {
|
||||
return "Local Workspace • Remote Broker Attached"
|
||||
}
|
||||
|
|
@ -3285,6 +3465,51 @@ struct ContentView: View {
|
|||
: "Local Workspace • Remote Ready"
|
||||
}
|
||||
|
||||
private var remoteSessionBadgeForegroundColor: Color {
|
||||
remoteSessionStore.runtimeState == .failed ? .red : .secondary
|
||||
}
|
||||
|
||||
private var remoteSessionBadgeBackgroundColor: Color {
|
||||
remoteSessionStore.runtimeState == .failed
|
||||
? Color.red.opacity(0.16)
|
||||
: Color.secondary.opacity(0.16)
|
||||
}
|
||||
|
||||
private var remoteSessionBadgeAccessibilityValue: String {
|
||||
if remoteSessionStore.runtimeState == .failed, remoteSessionStore.isBrokerClientAttached {
|
||||
return "Local workspace lost its attached remote broker session. Reattach from Settings using a fresh code."
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed, remoteSessionStore.hasBrokerSession {
|
||||
return "Local workspace lost the active macOS remote broker session. Restart the Mac session before attaching again."
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed {
|
||||
return "Local workspace remote session failed."
|
||||
}
|
||||
return remoteSessionStore.isBrokerClientAttached
|
||||
? "Local workspace attached to a remote broker for read-only browsing"
|
||||
: (
|
||||
remoteSessionStore.hasBrokerSession
|
||||
? "Local workspace with an active remote broker session on macOS"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewConnecting
|
||||
? "Local workspace with a remote session connection in progress"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewConnected
|
||||
? "Local workspace with an active remote session connection"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewReady
|
||||
? "Local workspace with a selected remote preview target"
|
||||
: (
|
||||
remotePreparedTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Local workspace with remote preview enabled"
|
||||
: "Local workspace with a prepared remote target"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var windowSubtitleText: String {
|
||||
[largeFileStatusBadgeText, remoteSessionStatusBadgeText]
|
||||
.filter { !$0.isEmpty }
|
||||
|
|
@ -4537,7 +4762,7 @@ struct ContentView: View {
|
|||
|
||||
private var markdownPreviewRegularHeader: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Markdown Preview")
|
||||
Text(NSLocalizedString("Markdown Preview", comment: ""))
|
||||
.font(.headline)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
|
|
@ -4548,6 +4773,14 @@ struct ContentView: View {
|
|||
|
||||
markdownPreviewSecondaryActionRow
|
||||
.padding(.top, 2)
|
||||
|
||||
Text(markdownPreviewExportSummaryText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityLabel(NSLocalizedString("Markdown preview export summary", comment: ""))
|
||||
|
||||
markdownPreviewActionStatusView
|
||||
}
|
||||
#if os(iOS)
|
||||
.frame(minWidth: 320, maxWidth: 420)
|
||||
|
|
@ -4562,7 +4795,7 @@ struct ContentView: View {
|
|||
|
||||
private var markdownPreviewIPadHeader: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Markdown Preview")
|
||||
Text(NSLocalizedString("Markdown Preview", comment: ""))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
|
|
@ -4574,6 +4807,14 @@ struct ContentView: View {
|
|||
|
||||
markdownPreviewSecondaryActionRow
|
||||
.padding(.top, 2)
|
||||
|
||||
Text(markdownPreviewExportSummaryText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityLabel(NSLocalizedString("Markdown preview export summary", comment: ""))
|
||||
|
||||
markdownPreviewActionStatusView
|
||||
}
|
||||
.frame(maxWidth: 460)
|
||||
.padding(16)
|
||||
|
|
@ -4583,21 +4824,21 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private var markdownPreviewTemplatePicker: some View {
|
||||
Picker("Template", selection: $markdownPreviewTemplateRaw) {
|
||||
Text("Default").tag("default")
|
||||
Text("Docs").tag("docs")
|
||||
Text("Article").tag("article")
|
||||
Text("Compact").tag("compact")
|
||||
Text("GitHub Docs").tag("github-docs")
|
||||
Text("Academic Paper").tag("academic-paper")
|
||||
Text("Terminal Notes").tag("terminal-notes")
|
||||
Text("Magazine").tag("magazine")
|
||||
Text("Minimal Reader").tag("minimal-reader")
|
||||
Text("Presentation").tag("presentation")
|
||||
Text("Night Contrast").tag("night-contrast")
|
||||
Text("Warm Sepia").tag("warm-sepia")
|
||||
Text("Dense Compact").tag("dense-compact")
|
||||
Text("Developer Spec").tag("developer-spec")
|
||||
Picker(NSLocalizedString("Template", comment: ""), selection: $markdownPreviewTemplateRaw) {
|
||||
Text(NSLocalizedString("Default", comment: "")).tag("default")
|
||||
Text(NSLocalizedString("Docs", comment: "")).tag("docs")
|
||||
Text(NSLocalizedString("Article", comment: "")).tag("article")
|
||||
Text(NSLocalizedString("Compact", comment: "")).tag("compact")
|
||||
Text(NSLocalizedString("GitHub Docs", comment: "")).tag("github-docs")
|
||||
Text(NSLocalizedString("Academic Paper", comment: "")).tag("academic-paper")
|
||||
Text(NSLocalizedString("Terminal Notes", comment: "")).tag("terminal-notes")
|
||||
Text(NSLocalizedString("Magazine", comment: "")).tag("magazine")
|
||||
Text(NSLocalizedString("Minimal Reader", comment: "")).tag("minimal-reader")
|
||||
Text(NSLocalizedString("Presentation", comment: "")).tag("presentation")
|
||||
Text(NSLocalizedString("Night Contrast", comment: "")).tag("night-contrast")
|
||||
Text(NSLocalizedString("Warm Sepia", comment: "")).tag("warm-sepia")
|
||||
Text(NSLocalizedString("Dense Compact", comment: "")).tag("dense-compact")
|
||||
Text(NSLocalizedString("Developer Spec", comment: "")).tag("developer-spec")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
|
@ -4609,9 +4850,9 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private var markdownPreviewPDFModePicker: some View {
|
||||
Picker("PDF Mode", selection: $markdownPDFExportModeRaw) {
|
||||
Text("Paginated Fit").tag(MarkdownPDFExportMode.paginatedFit.rawValue)
|
||||
Text("One Page Fit").tag(MarkdownPDFExportMode.onePageFit.rawValue)
|
||||
Picker(NSLocalizedString("PDF Mode", comment: ""), selection: $markdownPDFExportModeRaw) {
|
||||
Text(NSLocalizedString("Paginated Fit", comment: "")).tag(MarkdownPDFExportMode.paginatedFit.rawValue)
|
||||
Text(NSLocalizedString("One Page Fit", comment: "")).tag(MarkdownPDFExportMode.onePageFit.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
|
@ -4626,7 +4867,7 @@ struct ContentView: View {
|
|||
Button {
|
||||
exportMarkdownPreviewPDF()
|
||||
} label: {
|
||||
Label("Export PDF", systemImage: "square.and.arrow.down")
|
||||
Label(NSLocalizedString("Export PDF", comment: ""), systemImage: "square.and.arrow.down")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
|
|
@ -4634,7 +4875,7 @@ struct ContentView: View {
|
|||
.tint(NeonUIStyle.accentBlue)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Export Markdown preview as PDF")
|
||||
.accessibilityLabel(NSLocalizedString("Export Markdown preview as PDF", comment: ""))
|
||||
}
|
||||
|
||||
private var markdownPreviewShareButton: some View {
|
||||
|
|
@ -4642,42 +4883,83 @@ struct ContentView: View {
|
|||
item: markdownPreviewShareHTML,
|
||||
preview: SharePreview("\(suggestedMarkdownPreviewBaseName()).html")
|
||||
) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
Label(NSLocalizedString("Share", comment: ""), systemImage: "square.and.arrow.up")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Share Markdown preview HTML")
|
||||
.accessibilityLabel(NSLocalizedString("Share Markdown preview HTML", comment: ""))
|
||||
}
|
||||
|
||||
private var markdownPreviewCopyHTMLButton: some View {
|
||||
Button {
|
||||
copyMarkdownPreviewHTML()
|
||||
} label: {
|
||||
Label("Copy HTML", systemImage: "doc.on.doc")
|
||||
Label(NSLocalizedString("Copy HTML", comment: ""), systemImage: "doc.on.doc")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Copy Markdown preview HTML")
|
||||
.accessibilityLabel(NSLocalizedString("Copy Markdown preview HTML", comment: ""))
|
||||
}
|
||||
|
||||
private var markdownPreviewCopyMarkdownButton: some View {
|
||||
Button {
|
||||
copyMarkdownPreviewMarkdown()
|
||||
} label: {
|
||||
Label("Copy Markdown", systemImage: "doc.on.clipboard")
|
||||
Label(NSLocalizedString("Copy Markdown", comment: ""), systemImage: "doc.on.clipboard")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel("Copy Markdown source")
|
||||
.accessibilityLabel(NSLocalizedString("Copy Markdown source", comment: ""))
|
||||
}
|
||||
|
||||
private var markdownPreviewExportSummaryText: String {
|
||||
"\(suggestedMarkdownPDFFilename()) • \(suggestedMarkdownPreviewBaseName()).html"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var markdownPreviewActionStatusView: some View {
|
||||
if !markdownPreviewActionStatusMessage.isEmpty {
|
||||
Text(markdownPreviewActionStatusMessage)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(NeonUIStyle.accentBlue)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityLabel(NSLocalizedString("Markdown preview action status", comment: ""))
|
||||
.accessibilityValue(markdownPreviewActionStatusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var markdownPreviewMoreActionsMenu: some View {
|
||||
Menu {
|
||||
Button {
|
||||
copyMarkdownPreviewHTML()
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy HTML", comment: ""), systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
copyMarkdownPreviewMarkdown()
|
||||
} label: {
|
||||
Label(NSLocalizedString("Copy Markdown", comment: ""), systemImage: "doc.on.clipboard")
|
||||
}
|
||||
} label: {
|
||||
Label(NSLocalizedString("More", comment: ""), systemImage: "ellipsis.circle")
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
.layoutPriority(1)
|
||||
.accessibilityLabel(NSLocalizedString("More Markdown preview actions", comment: ""))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -4773,7 +5055,7 @@ struct ContentView: View {
|
|||
@ViewBuilder
|
||||
private func markdownPreviewPickerColumn<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(title)
|
||||
Text(NSLocalizedString(title, comment: ""))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
|
@ -4839,17 +5121,18 @@ struct ContentView: View {
|
|||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 10) {
|
||||
markdownPreviewShareButton
|
||||
markdownPreviewCopyHTMLButton
|
||||
markdownPreviewMoreActionsMenu
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
markdownPreviewShareButton
|
||||
markdownPreviewCopyHTMLButton
|
||||
markdownPreviewMoreActionsMenu
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
markdownPreviewShareButton
|
||||
markdownPreviewMoreActionsMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4950,6 +5233,9 @@ struct ContentView: View {
|
|||
if !remoteSessionStatusBadgeText.isEmpty {
|
||||
remoteSessionBadge
|
||||
}
|
||||
if !selectedRemoteDocumentBadgeText.isEmpty {
|
||||
selectedRemoteDocumentBadge
|
||||
}
|
||||
Spacer()
|
||||
Text(effectiveLargeFileModeEnabled
|
||||
? "\(caretStatus) • Lines: \(statusLineCount)\(vimStatusSuffix)"
|
||||
|
|
@ -4978,6 +5264,25 @@ struct ContentView: View {
|
|||
|
||||
private var remoteSessionBadge: some View {
|
||||
Text(remoteSessionStatusBadgeText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(remoteSessionBadgeForegroundColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(remoteSessionBadgeBackgroundColor)
|
||||
)
|
||||
.accessibilityLabel("Remote session status")
|
||||
.accessibilityValue(remoteSessionBadgeAccessibilityValue)
|
||||
}
|
||||
|
||||
private var selectedRemoteDocumentBadgeText: String {
|
||||
guard let tab = viewModel.selectedTab, tab.isRemoteDocument else { return "" }
|
||||
return tab.isReadOnlyPreview ? "Remote Document • Read-Only" : "Remote Document • Editable"
|
||||
}
|
||||
|
||||
private var selectedRemoteDocumentBadge: some View {
|
||||
Text(selectedRemoteDocumentBadgeText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
|
|
@ -4986,32 +5291,8 @@ struct ContentView: View {
|
|||
Capsule(style: .continuous)
|
||||
.fill(Color.secondary.opacity(0.16))
|
||||
)
|
||||
.accessibilityLabel("Remote session status")
|
||||
.accessibilityValue(
|
||||
remoteSessionStore.isBrokerClientAttached
|
||||
? "Local workspace attached to a remote broker for read-only browsing"
|
||||
: (
|
||||
remoteSessionStore.hasBrokerSession
|
||||
? "Local workspace with an active remote broker session on macOS"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewConnecting
|
||||
? "Local workspace with a remote session connection in progress"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewConnected
|
||||
? "Local workspace with an active remote session connection"
|
||||
: (
|
||||
remoteSessionStore.isRemotePreviewReady
|
||||
? "Local workspace with a selected remote preview target"
|
||||
: (
|
||||
remotePreparedTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Local workspace with remote preview enabled"
|
||||
: "Local workspace with a prepared remote target"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Selected document status")
|
||||
.accessibilityValue(selectedRemoteDocumentBadgeText)
|
||||
}
|
||||
|
||||
private var largeFileSessionBadge: some View {
|
||||
|
|
@ -5069,6 +5350,34 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tabRemoteBadge(for tab: TabData) -> some View {
|
||||
if tab.isRemoteDocument {
|
||||
Text("Remote")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(viewModel.selectedTabID == tab.id ? Color.accentColor : Color.secondary)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color.accentColor.opacity(viewModel.selectedTabID == tab.id ? 0.16 : 0.10))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func tabAccessibilityLabel(for tab: TabData) -> String {
|
||||
var parts: [String] = [tab.name]
|
||||
if tab.isRemoteDocument {
|
||||
parts.append(tab.isReadOnlyPreview ? "remote read only document" : "remote editable document")
|
||||
} else {
|
||||
parts.append("local document")
|
||||
}
|
||||
if tab.isDirty {
|
||||
parts.append("unsaved changes")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var tabBarView: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -5101,6 +5410,7 @@ struct ContentView: View {
|
|||
viewModel.selectTab(id: tab.id)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
tabRemoteBadge(for: tab)
|
||||
Text(tab.name + (tab.isDirty ? " •" : ""))
|
||||
.lineLimit(1)
|
||||
.font(.system(size: 12, weight: viewModel.selectedTabID == tab.id ? .semibold : .regular))
|
||||
|
|
@ -5114,6 +5424,8 @@ struct ContentView: View {
|
|||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tabAccessibilityLabel(for: tab))
|
||||
.accessibilityHint("Selects this editor tab.")
|
||||
#if os(macOS)
|
||||
.simultaneousGesture(
|
||||
TapGesture(count: 2)
|
||||
|
|
@ -5286,6 +5598,24 @@ struct ContentView: View {
|
|||
return Array(ranked.prefix(300).map(\.0))
|
||||
}
|
||||
|
||||
private var quickSwitcherStatusMessage: String {
|
||||
guard projectRootFolderURL != nil else { return "No project folder is open." }
|
||||
if isProjectFileIndexing {
|
||||
if projectFileIndexSnapshot.entries.isEmpty {
|
||||
return "Indexing project files for Quick Open…"
|
||||
}
|
||||
return "Refreshing indexed project files…"
|
||||
}
|
||||
if !projectFileIndexSnapshot.entries.isEmpty {
|
||||
let fileCount = projectFileIndexSnapshot.entries.count
|
||||
return "Using indexed project files (\(fileCount))."
|
||||
}
|
||||
if !quickSwitcherProjectFileURLs.isEmpty {
|
||||
return "Using the current project tree until indexing is available."
|
||||
}
|
||||
return "Project files will appear here after the folder is indexed."
|
||||
}
|
||||
|
||||
private func selectQuickSwitcherItem(_ item: QuickFileSwitcherPanel.Item) {
|
||||
rememberQuickSwitcherSelection(item.id)
|
||||
if item.id.hasPrefix("cmd:") {
|
||||
|
|
@ -5366,16 +5696,113 @@ struct ContentView: View {
|
|||
return max(0, 120 - (index * 5))
|
||||
}
|
||||
|
||||
private func quickSwitcherPathComponents(for item: QuickFileSwitcherPanel.Item) -> [String] {
|
||||
item.subtitle
|
||||
.split(separator: "/")
|
||||
.map { String($0).lowercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func quickSwitcherTitleStem(for item: QuickFileSwitcherPanel.Item) -> String {
|
||||
URL(fileURLWithPath: item.title).deletingPathExtension().lastPathComponent.lowercased()
|
||||
}
|
||||
|
||||
private func quickSwitcherTokenPrefixScore(for query: String, in value: String, score: Int) -> Int? {
|
||||
let separators = CharacterSet.alphanumerics.inverted
|
||||
let tokens = value
|
||||
.components(separatedBy: separators)
|
||||
.filter { !$0.isEmpty }
|
||||
return tokens.contains(where: { $0.hasPrefix(query) }) ? score : nil
|
||||
}
|
||||
|
||||
private func quickSwitcherQueryTokens(for query: String) -> [String] {
|
||||
query
|
||||
.lowercased()
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "/" || $0 == "_" || $0 == "-" || $0 == "." })
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func quickSwitcherMultiTokenScore(
|
||||
tokens: [String],
|
||||
title: String,
|
||||
subtitle: String,
|
||||
pathComponents: [String]
|
||||
) -> Int? {
|
||||
guard tokens.count > 1 else { return nil }
|
||||
|
||||
let titleTokens = title
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
let subtitleTokens = subtitle
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let allTitlePrefix = tokens.allSatisfy { queryToken in
|
||||
titleTokens.contains(where: { $0.hasPrefix(queryToken) })
|
||||
}
|
||||
if allTitlePrefix {
|
||||
return 390
|
||||
}
|
||||
|
||||
let allPathPrefix = tokens.allSatisfy { queryToken in
|
||||
pathComponents.contains(where: { $0.hasPrefix(queryToken) })
|
||||
}
|
||||
if allPathPrefix {
|
||||
return 340
|
||||
}
|
||||
|
||||
let allDistributedPrefix = tokens.allSatisfy { queryToken in
|
||||
titleTokens.contains(where: { $0.hasPrefix(queryToken) }) ||
|
||||
subtitleTokens.contains(where: { $0.hasPrefix(queryToken) }) ||
|
||||
pathComponents.contains(where: { $0.hasPrefix(queryToken) })
|
||||
}
|
||||
if allDistributedPrefix {
|
||||
return 300
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func quickSwitcherMatchScore(for item: QuickFileSwitcherPanel.Item, query: String) -> Int? {
|
||||
let normalizedQuery = query.lowercased()
|
||||
let queryTokens = quickSwitcherQueryTokens(for: query)
|
||||
let title = item.title.lowercased()
|
||||
let subtitle = item.subtitle.lowercased()
|
||||
let titleStem = quickSwitcherTitleStem(for: item)
|
||||
let pathComponents = quickSwitcherPathComponents(for: item)
|
||||
if title == normalizedQuery {
|
||||
return 420
|
||||
}
|
||||
if titleStem == normalizedQuery {
|
||||
return 400
|
||||
}
|
||||
if let score = quickSwitcherMultiTokenScore(
|
||||
tokens: queryTokens,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
pathComponents: pathComponents
|
||||
) {
|
||||
return score
|
||||
}
|
||||
if let score = quickSwitcherTokenPrefixScore(for: normalizedQuery, in: title, score: 370) {
|
||||
return score
|
||||
}
|
||||
if title.hasPrefix(normalizedQuery) {
|
||||
return 350
|
||||
}
|
||||
if pathComponents.contains(normalizedQuery) {
|
||||
return 320
|
||||
}
|
||||
if pathComponents.contains(where: { $0.hasPrefix(normalizedQuery) }) {
|
||||
return 290
|
||||
}
|
||||
if title.contains(normalizedQuery) {
|
||||
return 240
|
||||
}
|
||||
if let score = quickSwitcherTokenPrefixScore(for: normalizedQuery, in: subtitle, score: 210) {
|
||||
return score
|
||||
}
|
||||
if subtitle.contains(normalizedQuery) {
|
||||
return 180
|
||||
}
|
||||
|
|
@ -5410,23 +5837,33 @@ struct ContentView: View {
|
|||
guard let root = projectRootFolderURL else {
|
||||
findInFilesResults = []
|
||||
findInFilesStatusMessage = "Open a project folder first."
|
||||
findInFilesSourceMessage = ""
|
||||
return
|
||||
}
|
||||
let query = findInFilesQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else {
|
||||
findInFilesResults = []
|
||||
findInFilesStatusMessage = "Enter a search query."
|
||||
findInFilesSourceMessage = ""
|
||||
return
|
||||
}
|
||||
|
||||
findInFilesTask?.cancel()
|
||||
let indexedProjectFileURLs = projectFileIndexSnapshot.fileURLs
|
||||
let candidateFiles = indexedProjectFileURLs.isEmpty ? nil : indexedProjectFileURLs
|
||||
let searchSourceMessage: String
|
||||
if candidateFiles == nil, isProjectFileIndexing {
|
||||
findInFilesStatusMessage = "Searching while project index updates…"
|
||||
searchSourceMessage = "Live filesystem scan while the project index refreshes."
|
||||
} else {
|
||||
findInFilesStatusMessage = "Searching…"
|
||||
if let candidateFiles {
|
||||
searchSourceMessage = "Searching \(candidateFiles.count) indexed project files."
|
||||
} else {
|
||||
searchSourceMessage = "Searching the live project tree because no index is available yet."
|
||||
}
|
||||
}
|
||||
findInFilesSourceMessage = searchSourceMessage
|
||||
|
||||
let caseSensitive = findInFilesCaseSensitive
|
||||
findInFilesTask = Task {
|
||||
|
|
@ -5442,16 +5879,29 @@ struct ContentView: View {
|
|||
if results.isEmpty {
|
||||
findInFilesStatusMessage = "No matches found."
|
||||
} else {
|
||||
findInFilesStatusMessage = "\(results.count) matches"
|
||||
findInFilesStatusMessage = String.localizedStringWithFormat(
|
||||
NSLocalizedString("%lld matches", comment: ""),
|
||||
Int64(results.count)
|
||||
)
|
||||
}
|
||||
findInFilesSourceMessage = searchSourceMessage
|
||||
}
|
||||
}
|
||||
|
||||
private func clearFindInFiles() {
|
||||
findInFilesTask?.cancel()
|
||||
findInFilesQuery = ""
|
||||
findInFilesResults = []
|
||||
findInFilesStatusMessage = ""
|
||||
findInFilesSourceMessage = ""
|
||||
}
|
||||
|
||||
private func selectFindInFilesMatch(_ match: FindInFilesMatch) {
|
||||
openProjectFile(url: match.fileURL)
|
||||
var userInfo: [String: Any] = [
|
||||
EditorCommandUserInfo.rangeLocation: match.rangeLocation,
|
||||
EditorCommandUserInfo.rangeLength: match.rangeLength
|
||||
EditorCommandUserInfo.rangeLength: match.rangeLength,
|
||||
EditorCommandUserInfo.focusEditor: true
|
||||
]
|
||||
#if os(macOS)
|
||||
if let hostWindowNumber {
|
||||
|
|
|
|||
|
|
@ -2069,7 +2069,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
textView.textColor = baseTextColor
|
||||
textView.insertionPointColor = NSColor(theme.cursor)
|
||||
textView.selectedTextAttributes = [
|
||||
.backgroundColor: NSColor(theme.selection)
|
||||
.backgroundColor: resolvedSelectionColor(for: theme)
|
||||
]
|
||||
textView.usesInspectorBar = false
|
||||
textView.usesFontPanel = false
|
||||
|
|
@ -2313,7 +2313,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
textView.typingAttributes[.foregroundColor] = baseTextColor
|
||||
textView.selectedTextAttributes = [
|
||||
.backgroundColor: NSColor(theme.selection)
|
||||
.backgroundColor: resolvedSelectionColor(for: theme)
|
||||
]
|
||||
let showLineNumbersByDefault = showLineNumbers
|
||||
if textView.usesRuler != showLineNumbersByDefault {
|
||||
|
|
@ -2407,6 +2407,11 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private func resolvedSelectionColor(for theme: EditorTheme) -> NSColor {
|
||||
let base = NSColor(theme.selection)
|
||||
return base.blended(withFraction: colorScheme == .dark ? 0.18 : 0.12, of: .white) ?? base
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
|
@ -4484,9 +4489,14 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let textLength = (textView.text as NSString?)?.length ?? 0
|
||||
guard location >= 0, length >= 0, location + length <= textLength else { return }
|
||||
let range = NSRange(location: location, length: length)
|
||||
textView.becomeFirstResponder()
|
||||
textView.selectedRange = range
|
||||
textView.scrollRangeToVisible(range)
|
||||
let shouldFocusEditor = notification.userInfo?[EditorCommandUserInfo.focusEditor] as? Bool ?? true
|
||||
DispatchQueue.main.async {
|
||||
if shouldFocusEditor {
|
||||
textView.becomeFirstResponder()
|
||||
}
|
||||
textView.selectedRange = range
|
||||
textView.scrollRangeToVisible(range)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func moveToLine(_ notification: Notification) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ struct IPadKeyboardShortcutBridge: UIViewRepresentable {
|
|||
let onFind: () -> Void
|
||||
let onFindInFiles: () -> Void
|
||||
let onQuickOpen: () -> Void
|
||||
let onToggleSidebar: () -> Void
|
||||
let onToggleProjectSidebar: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> KeyboardCommandView {
|
||||
let view = KeyboardCommandView()
|
||||
|
|
@ -22,6 +24,8 @@ struct IPadKeyboardShortcutBridge: UIViewRepresentable {
|
|||
view.onFind = onFind
|
||||
view.onFindInFiles = onFindInFiles
|
||||
view.onQuickOpen = onQuickOpen
|
||||
view.onToggleSidebar = onToggleSidebar
|
||||
view.onToggleProjectSidebar = onToggleProjectSidebar
|
||||
return view
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +36,8 @@ struct IPadKeyboardShortcutBridge: UIViewRepresentable {
|
|||
uiView.onFind = onFind
|
||||
uiView.onFindInFiles = onFindInFiles
|
||||
uiView.onQuickOpen = onQuickOpen
|
||||
uiView.onToggleSidebar = onToggleSidebar
|
||||
uiView.onToggleProjectSidebar = onToggleProjectSidebar
|
||||
uiView.refreshFirstResponderStatus()
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +49,8 @@ final class KeyboardCommandView: UIView {
|
|||
var onFind: (() -> Void)?
|
||||
var onFindInFiles: (() -> Void)?
|
||||
var onQuickOpen: (() -> Void)?
|
||||
var onToggleSidebar: (() -> Void)?
|
||||
var onToggleProjectSidebar: (() -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
|
|
@ -60,6 +68,10 @@ final class KeyboardCommandView: UIView {
|
|||
findInFilesCommand.discoverabilityTitle = "Find in Files"
|
||||
let quickOpenCommand = UIKeyCommand(input: "p", modifierFlags: .command, action: #selector(quickOpen))
|
||||
quickOpenCommand.discoverabilityTitle = "Quick Open"
|
||||
let toggleSidebarCommand = UIKeyCommand(input: "s", modifierFlags: [.command, .alternate], action: #selector(handleToggleSidebarCommand))
|
||||
toggleSidebarCommand.discoverabilityTitle = "Toggle Sidebar"
|
||||
let toggleProjectSidebarCommand = UIKeyCommand(input: "p", modifierFlags: [.command, .alternate], action: #selector(handleToggleProjectSidebarCommand))
|
||||
toggleProjectSidebarCommand.discoverabilityTitle = "Toggle Project Structure Sidebar"
|
||||
|
||||
return [
|
||||
newTabCommand,
|
||||
|
|
@ -67,7 +79,9 @@ final class KeyboardCommandView: UIView {
|
|||
saveCommand,
|
||||
findCommand,
|
||||
findInFilesCommand,
|
||||
quickOpenCommand
|
||||
quickOpenCommand,
|
||||
toggleSidebarCommand,
|
||||
toggleProjectSidebarCommand
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -89,5 +103,7 @@ final class KeyboardCommandView: UIView {
|
|||
@objc private func handleFindCommand() { onFind?() }
|
||||
@objc private func findInFiles() { onFindInFiles?() }
|
||||
@objc private func quickOpen() { onQuickOpen?() }
|
||||
@objc private func handleToggleSidebarCommand() { onToggleSidebar?() }
|
||||
@objc private func handleToggleProjectSidebarCommand() { onToggleProjectSidebar?() }
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -270,54 +270,95 @@ struct NeonSettingsView: View {
|
|||
self.supportsTranslucency = supportsTranslucency
|
||||
}
|
||||
|
||||
private var validSettingsTabTags: Set<String> {
|
||||
var tags: Set<String> = ["general", "editor", "templates", "themes"]
|
||||
#if os(iOS)
|
||||
tags.insert("more")
|
||||
#else
|
||||
tags.formUnion(["support", "ai", "remote"])
|
||||
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
|
||||
tags.insert("updates")
|
||||
}
|
||||
#endif
|
||||
return tags
|
||||
}
|
||||
|
||||
private func normalizeSettingsActiveTabIfNeeded() {
|
||||
let normalized = settingsActiveTab.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalized.isEmpty || !validSettingsTabTags.contains(normalized) {
|
||||
settingsActiveTab = Self.defaultSettingsTab
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedSettingsTabTags: [String] {
|
||||
#if os(iOS)
|
||||
["general", "editor", "templates", "themes", "more"]
|
||||
#else
|
||||
var tags = ["general", "editor", "templates", "themes", "support", "ai", "remote"]
|
||||
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
|
||||
tags.append("updates")
|
||||
}
|
||||
return tags
|
||||
#endif
|
||||
}
|
||||
|
||||
private func moveSettingsTabSelection(by delta: Int) {
|
||||
let availableTags = orderedSettingsTabTags.filter { validSettingsTabTags.contains($0) }
|
||||
guard !availableTags.isEmpty else { return }
|
||||
normalizeSettingsActiveTabIfNeeded()
|
||||
let currentIndex = availableTags.firstIndex(of: settingsActiveTab) ?? 0
|
||||
let nextIndex = min(max(currentIndex + delta, 0), availableTags.count - 1)
|
||||
settingsActiveTab = availableTags[nextIndex]
|
||||
}
|
||||
|
||||
private var settingsTabs: some View {
|
||||
TabView(selection: $settingsActiveTab) {
|
||||
SettingsTabPage(
|
||||
title: "General",
|
||||
title: localized("General"),
|
||||
systemImage: "gearshape",
|
||||
tag: "general",
|
||||
content: AnyView(generalTab)
|
||||
)
|
||||
SettingsTabPage(
|
||||
title: "Editor",
|
||||
title: localized("Editor"),
|
||||
systemImage: "slider.horizontal.3",
|
||||
tag: "editor",
|
||||
content: AnyView(editorTab)
|
||||
)
|
||||
SettingsTabPage(
|
||||
title: "Templates",
|
||||
title: localized("Templates"),
|
||||
systemImage: "doc.badge.plus",
|
||||
tag: "templates",
|
||||
content: AnyView(templateTab)
|
||||
)
|
||||
SettingsTabPage(
|
||||
title: "Themes",
|
||||
title: localized("Themes"),
|
||||
systemImage: "paintpalette",
|
||||
tag: "themes",
|
||||
content: AnyView(themeTab)
|
||||
)
|
||||
#if os(iOS)
|
||||
SettingsTabPage(
|
||||
title: "More",
|
||||
title: localized("More"),
|
||||
systemImage: "ellipsis.circle",
|
||||
tag: "more",
|
||||
content: AnyView(moreTab)
|
||||
)
|
||||
#else
|
||||
SettingsTabPage(
|
||||
title: "Support",
|
||||
title: localized("Support"),
|
||||
systemImage: "heart",
|
||||
tag: "support",
|
||||
content: AnyView(supportTab)
|
||||
)
|
||||
SettingsTabPage(
|
||||
title: "AI",
|
||||
title: localized("AI"),
|
||||
systemImage: "brain.head.profile",
|
||||
tag: "ai",
|
||||
content: AnyView(aiTab)
|
||||
)
|
||||
SettingsTabPage(
|
||||
title: "Remote",
|
||||
title: localized("Remote"),
|
||||
systemImage: "rectangle.connected.to.line.below",
|
||||
tag: "remote",
|
||||
content: AnyView(remoteTab)
|
||||
|
|
@ -326,7 +367,7 @@ struct NeonSettingsView: View {
|
|||
#if os(macOS)
|
||||
if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution {
|
||||
SettingsTabPage(
|
||||
title: "Updates",
|
||||
title: localized("Updates"),
|
||||
systemImage: "arrow.triangle.2.circlepath.circle",
|
||||
tag: "updates",
|
||||
content: AnyView(updatesTab)
|
||||
|
|
@ -384,9 +425,16 @@ struct NeonSettingsView: View {
|
|||
#if os(iOS)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(
|
||||
SettingsKeyboardShortcutBridge(
|
||||
onMoveToPreviousTab: { moveSettingsTabSelection(by: -1) },
|
||||
onMoveToNextTab: { moveSettingsTabSelection(by: 1) }
|
||||
)
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
#endif
|
||||
.onAppear {
|
||||
settingsActiveTab = Self.defaultSettingsTab
|
||||
normalizeSettingsActiveTabIfNeeded()
|
||||
if moreSectionTab.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
moreSectionTab = "support"
|
||||
}
|
||||
|
|
@ -539,8 +587,8 @@ struct NeonSettingsView: View {
|
|||
settingsContainer {
|
||||
settingsSectionHeader(
|
||||
icon: "gearshape",
|
||||
title: "General",
|
||||
subtitle: "Window behavior, startup defaults, and confirmation preferences."
|
||||
title: LocalizedStringKey(localized("General")),
|
||||
subtitle: LocalizedStringKey(localized("Window behavior, startup defaults, and confirmation preferences."))
|
||||
)
|
||||
|
||||
#if os(iOS)
|
||||
|
|
@ -568,83 +616,83 @@ struct NeonSettingsView: View {
|
|||
private var windowSection: some View {
|
||||
#if os(iOS)
|
||||
settingsCardSection(
|
||||
title: "Window",
|
||||
title: LocalizedStringKey(localized("Window")),
|
||||
icon: "macwindow.badge.plus",
|
||||
showsAccentStripe: false,
|
||||
tip: "Choose how windows open and how appearance is applied."
|
||||
tip: LocalizedStringKey(localized("Choose how windows open and how appearance is applied."))
|
||||
) {
|
||||
if supportsOpenInTabs {
|
||||
iOSLabeledRow("Open in Tabs") {
|
||||
iOSLabeledRow(LocalizedStringKey(localized("Open in Tabs"))) {
|
||||
Picker("", selection: $openInTabs) {
|
||||
Text("Follow System").tag("system")
|
||||
Text("Always").tag("always")
|
||||
Text("Never").tag("never")
|
||||
Text(localized("Follow System")).tag("system")
|
||||
Text(localized("Always")).tag("always")
|
||||
Text(localized("Never")).tag("never")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
iOSLabeledRow("Appearance") {
|
||||
iOSLabeledRow(LocalizedStringKey(localized("Appearance"))) {
|
||||
Picker("", selection: $appearance) {
|
||||
Text("System").tag("system")
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
Text(localized("System")).tag("system")
|
||||
Text(localized("Light")).tag("light")
|
||||
Text(localized("Dark")).tag("dark")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if supportsTranslucency {
|
||||
iOSToggleRow("Translucent Window", isOn: $translucentWindow)
|
||||
iOSToggleRow(LocalizedStringKey(localized("Translucent Window")), isOn: $translucentWindow)
|
||||
}
|
||||
}
|
||||
#else
|
||||
GroupBox("Window") {
|
||||
GroupBox(localized("Window")) {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
if supportsOpenInTabs {
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Open in Tabs")
|
||||
Text(localized("Open in Tabs"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Picker("", selection: $openInTabs) {
|
||||
Text("Follow System").tag("system")
|
||||
Text("Always").tag("always")
|
||||
Text("Never").tag("never")
|
||||
Text(localized("Follow System")).tag("system")
|
||||
Text(localized("Always")).tag("always")
|
||||
Text(localized("Never")).tag("never")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Appearance")
|
||||
Text(localized("Appearance"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Picker("", selection: $appearance) {
|
||||
Text("System").tag("system")
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
Text(localized("System")).tag("system")
|
||||
Text(localized("Light")).tag("light")
|
||||
Text(localized("Dark")).tag("dark")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Toolbar Symbols")
|
||||
Text(localized("Toolbar Symbols"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Picker("", selection: $toolbarSymbolsColorMacRaw) {
|
||||
Text("Blue").tag("blue")
|
||||
Text("Dark Gray").tag("darkGray")
|
||||
Text("Black").tag("black")
|
||||
Text(localized("Blue")).tag("blue")
|
||||
Text(localized("Dark Gray")).tag("darkGray")
|
||||
Text(localized("Black")).tag("black")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if supportsTranslucency {
|
||||
Toggle("Translucent Window", isOn: $translucentWindow)
|
||||
Toggle(localized("Translucent Window"), isOn: $translucentWindow)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Translucency Mode")
|
||||
Text(localized("Translucency Mode"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Picker("", selection: $macTranslucencyModeRaw) {
|
||||
ForEach(MacTranslucencyModeOption.allCases) { option in
|
||||
Text(option.title).tag(option.rawValue)
|
||||
Text(localized(option.title)).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
|
@ -660,16 +708,16 @@ struct NeonSettingsView: View {
|
|||
private var editorFontSection: some View {
|
||||
#if os(iOS)
|
||||
settingsCardSection(
|
||||
title: "Editor Font",
|
||||
title: LocalizedStringKey(localized("Editor Font")),
|
||||
icon: "textformat",
|
||||
emphasis: .secondary,
|
||||
showsAccentStripe: false
|
||||
) {
|
||||
iOSToggleRow("Use System Font", isOn: $useSystemFont)
|
||||
iOSToggleRow(LocalizedStringKey(localized("Use System Font")), isOn: $useSystemFont)
|
||||
|
||||
iOSLabeledRow("Font") {
|
||||
iOSLabeledRow(LocalizedStringKey(localized("Font"))) {
|
||||
Picker("", selection: selectedFontBinding) {
|
||||
Text("System").tag(systemFontSentinel)
|
||||
Text(localized("System")).tag(systemFontSentinel)
|
||||
ForEach(availableEditorFonts, id: \.self) { fontName in
|
||||
Text(fontName).tag(fontName)
|
||||
}
|
||||
|
|
@ -685,7 +733,7 @@ struct NeonSettingsView: View {
|
|||
.cornerRadius(UI.fieldCorner)
|
||||
}
|
||||
|
||||
iOSLabeledRow("Font Size") {
|
||||
iOSLabeledRow(LocalizedStringKey(localized("Font Size"))) {
|
||||
HStack(spacing: UI.space12) {
|
||||
Text(localized("%lld pt", Int64(Int(editorFontSize))))
|
||||
.font(.body.monospacedDigit())
|
||||
|
|
@ -697,7 +745,7 @@ struct NeonSettingsView: View {
|
|||
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: UI.space12) {
|
||||
Text("Line Height")
|
||||
Text(localized("Line Height"))
|
||||
.frame(width: iOSSettingsLabelWidth, alignment: .leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(String(format: "%.2fx", lineHeight))
|
||||
|
|
@ -708,27 +756,27 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
}
|
||||
#else
|
||||
GroupBox("Editor Font") {
|
||||
GroupBox(localized("Editor Font")) {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
Toggle("Use System Font", isOn: $useSystemFont)
|
||||
Toggle(localized("Use System Font"), isOn: $useSystemFont)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Font")
|
||||
Text(localized("Font"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
HStack(spacing: UI.space8) {
|
||||
Text(useSystemFont ? "System" : (editorFontName.isEmpty ? "System" : editorFontName))
|
||||
Text(useSystemFont ? localized("System") : (editorFontName.isEmpty ? localized("System") : editorFontName))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(showFontList ? "Hide Font List" : "Show Font List") {
|
||||
Button(showFontList ? localized("Hide Font List") : localized("Show Font List")) {
|
||||
showFontList.toggle()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
if showFontList {
|
||||
Picker("", selection: selectedFontBinding) {
|
||||
Text("System").tag(systemFontSentinel)
|
||||
Text(localized("System")).tag(systemFontSentinel)
|
||||
ForEach(availableEditorFonts, id: \.self) { fontName in
|
||||
Text(fontName).tag(fontName)
|
||||
}
|
||||
|
|
@ -746,7 +794,7 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240, alignment: .leading)
|
||||
#if os(macOS)
|
||||
Button("Choose…") {
|
||||
Button(localized("Choose…")) {
|
||||
useSystemFont = false
|
||||
showFontList = true
|
||||
}
|
||||
|
|
@ -755,7 +803,7 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Font Size")
|
||||
Text(localized("Font Size"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Stepper(value: $editorFontSize, in: 10...28, step: 1) {
|
||||
Text(localized("%lld pt", Int64(Int(editorFontSize))))
|
||||
|
|
@ -764,7 +812,7 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Line Height")
|
||||
Text(localized("Line Height"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : standardLabelWidth, alignment: .leading)
|
||||
Slider(value: $lineHeight, in: 1.0...1.8, step: 0.05)
|
||||
.frame(maxWidth: isCompactSettingsLayout ? .infinity : 240)
|
||||
|
|
@ -781,7 +829,7 @@ struct NeonSettingsView: View {
|
|||
Group {
|
||||
#if os(iOS)
|
||||
settingsCardSection(
|
||||
title: "Startup",
|
||||
title: LocalizedStringKey(localized("Startup")),
|
||||
icon: "bolt.horizontal",
|
||||
emphasis: .secondary,
|
||||
showsAccentStripe: false
|
||||
|
|
@ -789,7 +837,7 @@ struct NeonSettingsView: View {
|
|||
startupSectionContent
|
||||
}
|
||||
#else
|
||||
GroupBox("Startup") {
|
||||
GroupBox(localized("Startup")) {
|
||||
startupSectionContent
|
||||
.padding(UI.groupPadding)
|
||||
}
|
||||
|
|
@ -809,11 +857,11 @@ struct NeonSettingsView: View {
|
|||
|
||||
private var startupSectionContent: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
Toggle("Open with Blank Document", isOn: $openWithBlankDocument)
|
||||
Toggle(localized("Open with Blank Document"), isOn: $openWithBlankDocument)
|
||||
.disabled(reopenLastSession)
|
||||
Toggle("Reopen Last Session", isOn: $reopenLastSession)
|
||||
Toggle(localized("Reopen Last Session"), isOn: $reopenLastSession)
|
||||
HStack(alignment: .center, spacing: UI.space12) {
|
||||
Text("Default New File Language")
|
||||
Text(localized("Default New File Language"))
|
||||
.frame(width: isCompactSettingsLayout ? nil : startupLabelWidth, alignment: .leading)
|
||||
Picker("", selection: $defaultNewFileLanguage) {
|
||||
ForEach(templateLanguages, id: \.self) { lang in
|
||||
|
|
@ -822,7 +870,7 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
Text("Tip: Enable only one startup mode to keep app launch behavior predictable.")
|
||||
Text(localized("Tip: Enable only one startup mode to keep app launch behavior predictable."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -832,7 +880,7 @@ struct NeonSettingsView: View {
|
|||
Group {
|
||||
#if os(iOS)
|
||||
settingsCardSection(
|
||||
title: "Confirmations",
|
||||
title: LocalizedStringKey(localized("Confirmations")),
|
||||
icon: "checkmark.shield",
|
||||
emphasis: .secondary,
|
||||
showsAccentStripe: false
|
||||
|
|
@ -840,7 +888,7 @@ struct NeonSettingsView: View {
|
|||
confirmationsSectionContent
|
||||
}
|
||||
#else
|
||||
GroupBox("Confirmations") {
|
||||
GroupBox(localized("Confirmations")) {
|
||||
confirmationsSectionContent
|
||||
.padding(UI.groupPadding)
|
||||
}
|
||||
|
|
@ -850,8 +898,8 @@ struct NeonSettingsView: View {
|
|||
|
||||
private var confirmationsSectionContent: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
Toggle("Confirm Before Closing Dirty Tab", isOn: $confirmCloseDirtyTab)
|
||||
Toggle("Confirm Before Clearing Editor", isOn: $confirmClearEditor)
|
||||
Toggle(localized("Confirm Before Closing Dirty Tab"), isOn: $confirmCloseDirtyTab)
|
||||
Toggle(localized("Confirm Before Clearing Editor"), isOn: $confirmClearEditor)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -884,7 +932,7 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
|
||||
private var editorFontSummaryLabel: String {
|
||||
let fontLabel = useSystemFont ? "System" : (editorFontName.isEmpty ? "System" : editorFontName)
|
||||
let fontLabel = useSystemFont ? localized("System") : (editorFontName.isEmpty ? localized("System") : editorFontName)
|
||||
return "\(fontLabel) • \(Int(editorFontSize)) pt • \(String(format: "%.2fx", lineHeight))"
|
||||
}
|
||||
|
||||
|
|
@ -920,10 +968,10 @@ struct NeonSettingsView: View {
|
|||
|
||||
private var iPadQuickSummaryCard: some View {
|
||||
settingsCardSection(
|
||||
title: "Current Setup",
|
||||
title: LocalizedStringKey(localized("Current Setup")),
|
||||
icon: "rectangle.stack.badge.person.crop",
|
||||
showsAccentStripe: false,
|
||||
tip: "Snapshot updates immediately as settings change."
|
||||
tip: LocalizedStringKey(localized("Snapshot updates immediately as settings change."))
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
HStack(spacing: UI.space8) {
|
||||
|
|
@ -1836,6 +1884,14 @@ struct NeonSettingsView: View {
|
|||
if !remoteSessionsEnabled {
|
||||
return "Local workspace only. Remote modules stay inactive until you enable this preview."
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed,
|
||||
let attachedBroker = remoteSessionStore.attachedBrokerDescriptor {
|
||||
return "The broker session from \(attachedBroker.hostDisplayName) is no longer reachable for \(attachedBroker.targetSummary). Detach this device, then attach again using a fresh code from the active Mac session."
|
||||
}
|
||||
if remoteSessionStore.runtimeState == .failed,
|
||||
let broker = remoteSessionStore.brokerSessionDescriptor {
|
||||
return "The Mac-hosted broker session for \(broker.targetSummary) is no longer active. Start Session again on the Mac before sharing a new attach code."
|
||||
}
|
||||
if let attachedBroker = remoteSessionStore.attachedBrokerDescriptor {
|
||||
return "Attached to the Mac broker on \(attachedBroker.hostDisplayName) for \(attachedBroker.targetSummary). This device now browses, opens, edits, and explicitly saves supported remote text files through the Mac-hosted session."
|
||||
}
|
||||
|
|
@ -1968,6 +2024,12 @@ struct NeonSettingsView: View {
|
|||
: remoteSessionStore.sessionStatusDetail
|
||||
}
|
||||
|
||||
private func recoverRemoteBrokerAttachment() {
|
||||
remoteSessionStore.detachBrokerClient()
|
||||
remotePreparationStatus = "Paste a fresh attach code from the active Mac session to reattach."
|
||||
presentRemoteAttachSheet()
|
||||
}
|
||||
|
||||
private func removeRemoteTarget(_ target: RemoteSessionStore.SavedTarget) {
|
||||
let wasActive = remoteSessionStore.activeTargetID == target.id
|
||||
remoteSessionStore.removeSavedTarget(id: target.id)
|
||||
|
|
@ -2087,27 +2149,31 @@ struct NeonSettingsView: View {
|
|||
#elseif canImport(UIKit)
|
||||
UIPasteboard.general.string = code
|
||||
#endif
|
||||
remotePreparationStatus = "Copied the broker attach code."
|
||||
remotePreparationStatus = localized("Copied the broker attach code.")
|
||||
}
|
||||
|
||||
private var remoteHelpSection: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space10) {
|
||||
Label("How To Connect", systemImage: "questionmark.circle")
|
||||
Label(localized("How To Connect"), systemImage: "questionmark.circle")
|
||||
.font(.headline)
|
||||
|
||||
Text("On the Mac: enable Remote, open Connect, enter the SSH target server host, user, and port, optionally choose an SSH key, then press Connect Locally and Start Session. The SSH target server must be a real machine or service running an SSH server, not an iPhone or iPad simulator.")
|
||||
Text(localized("On the Mac: enable Remote, open Connect, enter the SSH target server host, user, and port, optionally choose an SSH key, then press Connect Locally and Start Session. The SSH target server must be a real machine or service running an SSH server, not an iPhone or iPad simulator."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("If you use your local Mac as the SSH target with 127.0.0.1:22 and see 'connection refused', your Mac is not running an SSH server yet. Open System Settings > General > Sharing and enable Remote Login, then try Start Session again.")
|
||||
Text(localized("If you use your local Mac as the SSH target with 127.0.0.1:22 and see 'connection refused', your Mac is not running an SSH server yet. Open System Settings > General > Sharing and enable Remote Login, then try Start Session again."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("On iPhone or iPad: do not enter an SSH key. Copy the Attach Code from the active Mac broker, open Attach to Broker, paste the code, and attach.")
|
||||
Text(localized("On iPhone or iPad: do not enter an SSH key. Copy the Attach Code from the active Mac broker, open Attach to Broker, paste the code, and attach."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("After attaching: use Remote Browser to open a supported text file, edit it in the editor, and use Save to write it back to the same remote path through the Mac-hosted session. Save As stays local-only.")
|
||||
Text(localized("After attaching: use Remote Browser to open a supported text file, edit it in the editor, and use Save to write it back to the same remote path through the Mac-hosted session. Save As stays local-only."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(localized("If the Mac-hosted broker session disappears while editing, Save will stop and the app will ask you to detach and reattach. Restart the session on the Mac first if needed, then use a fresh attach code."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -2129,13 +2195,13 @@ struct NeonSettingsView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: UI.space8) {
|
||||
Button(remoteSessionStore.activeTargetID == target.id ? "Selected" : "Use Saved Target") {
|
||||
Button(remoteSessionStore.activeTargetID == target.id ? localized("Selected") : localized("Use Saved Target")) {
|
||||
activateRemoteTarget(target)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!remoteSessionsEnabled || remoteSessionStore.activeTargetID == target.id)
|
||||
|
||||
Button("Remove") {
|
||||
Button(localized("Remove")) {
|
||||
removeRemoteTarget(target)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
@ -2152,37 +2218,37 @@ struct NeonSettingsView: View {
|
|||
private var remoteSessionActionButtons: some View {
|
||||
ViewThatFits {
|
||||
HStack(spacing: UI.space12) {
|
||||
Button("Connect…") {
|
||||
Button(localized("Connect…")) {
|
||||
presentRemoteConnectSheet()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!remoteSessionsEnabled)
|
||||
|
||||
Button("Attach…") {
|
||||
Button(localized("Attach…")) {
|
||||
presentRemoteAttachSheet()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionsEnabled || remoteSessionStore.isBrokerClientAttached)
|
||||
|
||||
Button("Start Session") {
|
||||
Button(localized("Start Session")) {
|
||||
startRemoteSession()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionsEnabled || !remoteSessionStore.isRemotePreviewReady || remoteSessionStore.isRemotePreviewConnected || remoteSessionStore.isRemotePreviewConnecting)
|
||||
|
||||
Button("Stop Session") {
|
||||
Button(localized("Stop Session")) {
|
||||
stopRemoteSession()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionStore.isRemotePreviewConnected && !remoteSessionStore.isRemotePreviewConnecting)
|
||||
|
||||
Button("Disconnect") {
|
||||
Button(localized("Disconnect")) {
|
||||
disconnectRemotePreview()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionStore.isRemotePreviewReady && remotePreparedTarget.isEmpty)
|
||||
|
||||
Button("Detach Broker") {
|
||||
Button(localized("Detach Broker")) {
|
||||
detachRemoteBroker()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
@ -2190,37 +2256,37 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: UI.space8) {
|
||||
Button("Connect…") {
|
||||
Button(localized("Connect…")) {
|
||||
presentRemoteConnectSheet()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!remoteSessionsEnabled)
|
||||
|
||||
Button("Attach…") {
|
||||
Button(localized("Attach…")) {
|
||||
presentRemoteAttachSheet()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionsEnabled || remoteSessionStore.isBrokerClientAttached)
|
||||
|
||||
Button("Start Session") {
|
||||
Button(localized("Start Session")) {
|
||||
startRemoteSession()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionsEnabled || !remoteSessionStore.isRemotePreviewReady || remoteSessionStore.isRemotePreviewConnected || remoteSessionStore.isRemotePreviewConnecting)
|
||||
|
||||
Button("Stop Session") {
|
||||
Button(localized("Stop Session")) {
|
||||
stopRemoteSession()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionStore.isRemotePreviewConnected && !remoteSessionStore.isRemotePreviewConnecting)
|
||||
|
||||
Button("Disconnect") {
|
||||
Button(localized("Disconnect")) {
|
||||
disconnectRemotePreview()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!remoteSessionStore.isRemotePreviewReady && remotePreparedTarget.isEmpty)
|
||||
|
||||
Button("Detach Broker") {
|
||||
Button(localized("Detach Broker")) {
|
||||
detachRemoteBroker()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
@ -2280,10 +2346,19 @@ struct NeonSettingsView: View {
|
|||
loadRemoteBrowserPath(parentPath.isEmpty ? "/" : parentPath)
|
||||
}
|
||||
|
||||
private func browseRemoteHomeDirectory() {
|
||||
loadRemoteBrowserPath("~")
|
||||
}
|
||||
|
||||
private func applyRemoteBrowserPathDraft() {
|
||||
loadRemoteBrowserPath(remoteBrowserPathDraft)
|
||||
}
|
||||
|
||||
private func retryRemoteBrowserLoad() {
|
||||
let requestedPath = remoteBrowserPathDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
loadRemoteBrowserPath(requestedPath.isEmpty ? remoteSessionStore.remoteBrowserPath : requestedPath)
|
||||
}
|
||||
|
||||
private func openRemoteFile(_ entry: RemoteSessionStore.RemoteFileEntry) {
|
||||
Task {
|
||||
guard let document = await remoteSessionStore.openRemoteDocument(path: entry.path) else {
|
||||
|
|
@ -2362,39 +2437,87 @@ struct NeonSettingsView: View {
|
|||
(remoteSessionStore.isRemotePreviewConnected && remoteSessionStore.activeTarget?.sshKeyBookmarkData != nil)
|
||||
}
|
||||
|
||||
private var showsRemoteBrowserRecoveryActions: Bool {
|
||||
remoteSessionStore.isBrokerClientAttached && remoteSessionStore.runtimeState == .failed
|
||||
}
|
||||
|
||||
private var showsRemoteBrowserRetryAction: Bool {
|
||||
!remoteSessionStore.isRemoteBrowserLoading &&
|
||||
remoteSessionStore.remoteBrowserEntries.isEmpty &&
|
||||
!remoteSessionStore.remoteBrowserStatusDetail.isEmpty
|
||||
}
|
||||
|
||||
private var remoteBrowserSection: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
HStack(spacing: UI.space12) {
|
||||
TextField("Remote Path", text: $remoteBrowserPathDraft)
|
||||
TextField(localized("Remote Path"), text: $remoteBrowserPathDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
applyRemoteBrowserPathDraft()
|
||||
}
|
||||
|
||||
Button("Refresh") {
|
||||
Button(localized("Refresh")) {
|
||||
loadRemoteBrowserPath(remoteBrowserPathDraft)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading)
|
||||
|
||||
Button("Up") {
|
||||
Button(localized("Up")) {
|
||||
browseRemoteParentDirectory()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading || remoteSessionStore.remoteBrowserPath == "/" || remoteSessionStore.remoteBrowserPath == "~")
|
||||
|
||||
Button(localized("Home")) {
|
||||
browseRemoteHomeDirectory()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading || remoteSessionStore.remoteBrowserPath == "~")
|
||||
}
|
||||
|
||||
Text(remoteSessionStore.remoteBrowserStatusDetail.isEmpty ? "Browse the active remote session on demand. Supported text files open into the editor and save explicitly back to the remote path." : remoteSessionStore.remoteBrowserStatusDetail)
|
||||
Text("\(localized("Current Path:")) \(remoteSessionStore.remoteBrowserPath)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(remoteSessionStore.remoteBrowserStatusDetail.isEmpty ? localized("Browse the active remote session on demand. Supported text files open into the editor and save explicitly back to the remote path.") : remoteSessionStore.remoteBrowserStatusDetail)
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if showsRemoteBrowserRecoveryActions {
|
||||
HStack(spacing: UI.space10) {
|
||||
Button(localized("Retry Load")) {
|
||||
retryRemoteBrowserLoad()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(localized("Detach Broker")) {
|
||||
detachRemoteBroker()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button(localized("Reattach…")) {
|
||||
recoverRemoteBrokerAttachment()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
Text(localized("Restart the remote session on the Mac first if it is no longer active, then attach again with a fresh code."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if showsRemoteBrowserRetryAction {
|
||||
Button(localized("Retry Load")) {
|
||||
retryRemoteBrowserLoad()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if remoteSessionStore.isRemoteBrowserLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if remoteSessionStore.remoteBrowserEntries.isEmpty, !remoteSessionStore.remoteBrowserStatusDetail.isEmpty, !remoteSessionStore.isRemoteBrowserLoading {
|
||||
Text("No remote entries loaded yet.")
|
||||
Text(localized("No remote entries loaded yet."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
|
|
@ -2403,12 +2526,10 @@ struct NeonSettingsView: View {
|
|||
Button {
|
||||
if entry.isDirectory {
|
||||
loadRemoteBrowserPath(entry.path)
|
||||
} else if entry.isSupportedTextFile {
|
||||
openRemoteFile(entry)
|
||||
} else {
|
||||
#if os(macOS)
|
||||
openRemoteFile(entry)
|
||||
#else
|
||||
openRemoteFile(entry)
|
||||
#endif
|
||||
remotePreparationStatus = String(format: localized("%@ is not a supported text file for remote editing."), entry.name)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: UI.space8) {
|
||||
|
|
@ -2426,8 +2547,18 @@ struct NeonSettingsView: View {
|
|||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !entry.isSupportedTextFile {
|
||||
Text(localized("Unsupported"))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color.secondary.opacity(0.12))
|
||||
)
|
||||
} else {
|
||||
Text("Open Remote")
|
||||
Text(localized("Open Remote"))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -2440,12 +2571,18 @@ struct NeonSettingsView: View {
|
|||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading)
|
||||
.accessibilityLabel(entry.isDirectory ? "Open remote folder \(entry.name)" : "Remote file \(entry.name)")
|
||||
.disabled(remoteSessionStore.isRemoteBrowserLoading || (!entry.isDirectory && !entry.isSupportedTextFile))
|
||||
.accessibilityLabel(
|
||||
entry.isDirectory
|
||||
? String(format: localized("Open remote folder %@"), entry.name)
|
||||
: (entry.isSupportedTextFile ? String(format: localized("Remote file %@"), entry.name) : String(format: localized("Unsupported remote file %@"), entry.name))
|
||||
)
|
||||
.accessibilityHint(
|
||||
entry.isDirectory
|
||||
? "Loads the selected remote folder"
|
||||
: "Opens the selected remote file in the editor for explicit remote save"
|
||||
? localized("Loads the selected remote folder")
|
||||
: (entry.isSupportedTextFile
|
||||
? localized("Opens the selected remote file in the editor for explicit remote save")
|
||||
: localized("This remote file type is not supported for remote editing"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2458,29 +2595,29 @@ struct NeonSettingsView: View {
|
|||
|
||||
private var remoteConnectSheet: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space16) {
|
||||
Text("Remote Connect")
|
||||
Text(localized("Remote Connect"))
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("Connect stores and selects a remote target. On macOS, the Mac is the SSH owner: selecting an SSH key enables the Mac to perform the explicit SSH login only when you start a session. The target must be a real SSH server. Once connected, the Mac can publish an attach code so iPhone and iPad can browse, open, edit, and explicitly save supported text files through that brokered session.")
|
||||
Text(localized("Connect stores and selects a remote target. On macOS, the Mac is the SSH owner: selecting an SSH key enables the Mac to perform the explicit SSH login only when you start a session. The target must be a real SSH server. Once connected, the Mac can publish an attach code so iPhone and iPad can browse, open, edit, and explicitly save supported text files through that brokered session."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: UI.space12) {
|
||||
TextField("Nickname", text: $remoteConnectNickname)
|
||||
TextField(localized("Nickname"), text: $remoteConnectNickname)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Host", text: $remoteHost)
|
||||
TextField(localized("Host"), text: $remoteHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
TextField("User", text: $remoteUsername)
|
||||
TextField(localized("User"), text: $remoteUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
#endif
|
||||
TextField("Port", text: $remotePortDraft)
|
||||
TextField(localized("Port"), text: $remotePortDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
|
|
@ -2489,32 +2626,32 @@ struct NeonSettingsView: View {
|
|||
applyRemotePortDraft()
|
||||
}
|
||||
|
||||
Text("Port range: 1-65535. Port 22 is the standard SSH port.")
|
||||
Text(localized("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…") {
|
||||
Button(remoteSSHKeyBookmarkData == nil ? localized("Choose SSH Key…") : localized("Change SSH Key…")) {
|
||||
chooseRemoteSSHKey()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if remoteSSHKeyBookmarkData != nil {
|
||||
Button("Clear Key") {
|
||||
Button(localized("Clear Key")) {
|
||||
clearRemoteSSHKeySelection()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
Text(remoteSSHKeyDisplayName.isEmpty ? "No SSH key selected. Without a key, Start Session falls back to a TCP connection test from the Mac to the target host." : "Selected key: \(remoteSSHKeyDisplayName)")
|
||||
Text(remoteSSHKeyDisplayName.isEmpty ? localized("No SSH key selected. Without a key, Start Session falls back to a TCP connection test from the Mac to the target host.") : "\(localized("Selected key:")) \(remoteSSHKeyDisplayName)")
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#else
|
||||
Text("SSH-key login is currently available on macOS only. iPhone and iPad attach to the active Mac broker instead of handling SSH keys or direct SSH connections.")
|
||||
Text(localized("SSH-key login is currently available on macOS only. iPhone and iPad attach to the active Mac broker instead of handling SSH keys or direct SSH connections."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
|
|
@ -2523,12 +2660,12 @@ struct NeonSettingsView: View {
|
|||
HStack(spacing: UI.space12) {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
Button(localized("Cancel")) {
|
||||
showRemoteConnectSheet = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Connect Locally") {
|
||||
Button(localized("Connect Locally")) {
|
||||
connectRemotePreview()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
|
@ -2547,14 +2684,14 @@ struct NeonSettingsView: View {
|
|||
|
||||
private var remoteAttachSheet: some View {
|
||||
VStack(alignment: .leading, spacing: UI.space16) {
|
||||
Text("Attach to Broker")
|
||||
Text(localized("Attach to Broker"))
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("Paste the attach code from the active macOS broker session. This device does not use its own SSH key. After attaching, it browses, opens, edits, and explicitly saves supported text files through the Mac-hosted broker.")
|
||||
Text(localized("Paste the attach code from the active macOS broker session. This device does not use its own SSH key. After attaching, it browses, opens, edits, and explicitly saves supported text files through the Mac-hosted broker."))
|
||||
.font(Typography.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Attach Code", text: $remoteAttachCodeDraft, axis: .vertical)
|
||||
TextField(localized("Attach Code"), text: $remoteAttachCodeDraft, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
|
|
@ -2565,12 +2702,12 @@ struct NeonSettingsView: View {
|
|||
HStack(spacing: UI.space12) {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
Button(localized("Cancel")) {
|
||||
showRemoteAttachSheet = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Attach") {
|
||||
Button(localized("Attach")) {
|
||||
attachToRemoteBroker()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
|
@ -3611,6 +3748,62 @@ struct NeonSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private struct SettingsKeyboardShortcutBridge: UIViewRepresentable {
|
||||
let onMoveToPreviousTab: () -> Void
|
||||
let onMoveToNextTab: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> SettingsKeyboardCommandView {
|
||||
let view = SettingsKeyboardCommandView()
|
||||
view.onMoveToPreviousTab = onMoveToPreviousTab
|
||||
view.onMoveToNextTab = onMoveToNextTab
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SettingsKeyboardCommandView, context: Context) {
|
||||
uiView.onMoveToPreviousTab = onMoveToPreviousTab
|
||||
uiView.onMoveToNextTab = onMoveToNextTab
|
||||
uiView.refreshFirstResponderStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private final class SettingsKeyboardCommandView: UIView {
|
||||
var onMoveToPreviousTab: (() -> Void)?
|
||||
var onMoveToNextTab: (() -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return [] }
|
||||
let previousTabCommand = UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: .command, action: #selector(handlePreviousTabCommand))
|
||||
previousTabCommand.discoverabilityTitle = "Previous Settings Tab"
|
||||
let nextTabCommand = UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: .command, action: #selector(handleNextTabCommand))
|
||||
nextTabCommand.discoverabilityTitle = "Next Settings Tab"
|
||||
return [previousTabCommand, nextTabCommand]
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
refreshFirstResponderStatus()
|
||||
}
|
||||
|
||||
func refreshFirstResponderStatus() {
|
||||
guard window != nil, UIDevice.current.userInterfaceIdiom == .pad else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
_ = self?.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePreviousTabCommand() {
|
||||
onMoveToPreviousTab?()
|
||||
}
|
||||
|
||||
@objc private func handleNextTabCommand() {
|
||||
onMoveToNextTab?()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
struct SettingsWindowConfigurator: NSViewRepresentable {
|
||||
let minSize: NSSize
|
||||
|
|
@ -3778,7 +3971,6 @@ struct SettingsWindowConfigurator: NSViewRepresentable {
|
|||
object: window,
|
||||
queue: .main
|
||||
) { [weak coordinator] _ in
|
||||
UserDefaults.standard.set(NeonSettingsView.defaultSettingsTab, forKey: "SettingsActiveTab")
|
||||
centerSettingsWindow(window)
|
||||
coordinator?.didInitialApply = true
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -371,10 +371,12 @@ struct ProjectStructureSidebarView: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if showsInlineSidebarHeader {
|
||||
if showsSidebarActionsRow {
|
||||
HStack {
|
||||
Text("Project Structure")
|
||||
.font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold))
|
||||
if showsInlineSidebarTitle {
|
||||
Text("Project Structure")
|
||||
.font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onOpenFolder) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
|
|
@ -439,7 +441,7 @@ struct ProjectStructureSidebarView: View {
|
|||
.lineLimit(isCompactDensity ? 1 : 2)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, headerHorizontalPadding)
|
||||
.padding(.top, showsInlineSidebarHeader ? 0 : headerTopPadding)
|
||||
.padding(.top, showsSidebarActionsRow ? 0 : headerTopPadding)
|
||||
.padding(.bottom, headerPathBottomPadding)
|
||||
}
|
||||
|
||||
|
|
@ -726,7 +728,7 @@ struct ProjectStructureSidebarView: View {
|
|||
EdgeInsets(top: 2, leading: isCompactDensity ? 8 : 10, bottom: 2, trailing: isCompactDensity ? 8 : 10)
|
||||
}
|
||||
|
||||
private var showsInlineSidebarHeader: Bool {
|
||||
private var showsInlineSidebarTitle: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom != .phone
|
||||
#else
|
||||
|
|
@ -734,6 +736,10 @@ struct ProjectStructureSidebarView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private var showsSidebarActionsRow: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
private func directoryRowLeadingInset(for level: Int) -> CGFloat {
|
||||
let baseInset: CGFloat
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -126,6 +126,63 @@ private func modeAdjustedSyntaxColor(
|
|||
return blend(color, with: .white, amount: amountDark)
|
||||
}
|
||||
|
||||
private func emphasizedSelectionColor(for canonicalName: String, fallback: Color) -> Color {
|
||||
switch canonicalName {
|
||||
case "Neon Glow":
|
||||
return Color(red: 0.95, green: 0.17, blue: 0.56)
|
||||
case "Neon Flow":
|
||||
return Color(red: 0.95, green: 0.17, blue: 0.56)
|
||||
case "Neon Voltage":
|
||||
return Color(red: 0.00, green: 0.72, blue: 1.00)
|
||||
case "Laserwave":
|
||||
return Color(red: 0.98, green: 0.24, blue: 0.88)
|
||||
case "Cyber Lime":
|
||||
return Color(red: 0.45, green: 0.78, blue: 0.16)
|
||||
case "Plasma Storm":
|
||||
return Color(red: 0.58, green: 0.46, blue: 1.00)
|
||||
case "Inferno Neon":
|
||||
return Color(red: 1.00, green: 0.38, blue: 0.16)
|
||||
case "Ultraviolet Flux":
|
||||
return Color(red: 0.84, green: 0.36, blue: 1.00)
|
||||
case "Prism Daylight":
|
||||
return Color(red: 0.24, green: 0.50, blue: 0.96)
|
||||
case "Dracula":
|
||||
return Color(red: 1.00, green: 0.48, blue: 0.78)
|
||||
case "One Dark Pro":
|
||||
return Color(red: 0.35, green: 0.67, blue: 0.98)
|
||||
case "Nord":
|
||||
return Color(red: 0.56, green: 0.74, blue: 0.73)
|
||||
case "Tokyo Night":
|
||||
return Color(red: 0.48, green: 0.64, blue: 0.97)
|
||||
case "Gruvbox":
|
||||
return Color(red: 1.00, green: 0.50, blue: 0.10)
|
||||
case "Arc":
|
||||
return Color(red: 0.35, green: 0.67, blue: 0.98)
|
||||
case "Dusk":
|
||||
return Color(red: 0.93, green: 0.54, blue: 0.94)
|
||||
case "Aurora":
|
||||
return Color(red: 0.35, green: 0.96, blue: 0.76)
|
||||
case "Horizon":
|
||||
return Color(red: 0.99, green: 0.46, blue: 0.36)
|
||||
case "Midnight":
|
||||
return Color(red: 0.25, green: 0.78, blue: 0.98)
|
||||
case "Mono":
|
||||
return Color(red: 0.82, green: 0.82, blue: 0.82)
|
||||
case "Paper":
|
||||
return Color(red: 0.24, green: 0.50, blue: 0.96)
|
||||
case "Solar":
|
||||
return Color(red: 0.99, green: 0.64, blue: 0.24)
|
||||
case "Pulse":
|
||||
return Color(red: 0.98, green: 0.54, blue: 0.62)
|
||||
case "Mocha":
|
||||
return Color(red: 0.82, green: 0.60, blue: 0.98)
|
||||
case "Custom":
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
///MARK: Syntax Adjustment Profiles
|
||||
|
||||
// Internal strategy for token color adjustment per theme family.
|
||||
|
|
@ -583,7 +640,19 @@ private func paletteForThemeName(_ name: String, defaults: UserDefaults) -> Them
|
|||
)
|
||||
}
|
||||
}()
|
||||
return palette
|
||||
return ThemePalette(
|
||||
text: palette.text,
|
||||
background: palette.background,
|
||||
cursor: palette.cursor,
|
||||
selection: emphasizedSelectionColor(for: canonicalName, fallback: palette.selection),
|
||||
keyword: palette.keyword,
|
||||
string: palette.string,
|
||||
number: palette.number,
|
||||
comment: palette.comment,
|
||||
type: palette.type,
|
||||
property: palette.property,
|
||||
builtin: palette.builtin
|
||||
)
|
||||
}
|
||||
|
||||
func themePaletteColors(for name: String, defaults: UserDefaults = .standard) -> ThemePaletteColors {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,115 @@
|
|||
"Case Sensitive" = "Groß-/Kleinschreibung beachten";
|
||||
"Use Regex" = "Regex verwenden";
|
||||
"Quick Open" = "Schnell öffnen";
|
||||
"Find Next" = "Weitersuchen";
|
||||
"Jump to Match" = "Zum Treffer springen";
|
||||
"Search text" = "Suchtext";
|
||||
"Replacement" = "Ersatztext";
|
||||
"%lld match" = "%lld Treffer";
|
||||
"%lld matches" = "%lld Treffer";
|
||||
"Matches: %@" = "Treffer: %@";
|
||||
"No Matches Found" = "Keine Treffer gefunden";
|
||||
"Command Palette" = "Befehlspalette";
|
||||
"Search commands, files, and tabs" = "Befehle, Dateien und Tabs durchsuchen";
|
||||
"Command Palette Search" = "Befehlspalette-Suche";
|
||||
"Type to search commands, files, and tabs. Use Up and Down Arrow to move through results." = "Tippe, um Befehle, Dateien und Tabs zu durchsuchen. Nutze Pfeil hoch und runter, um durch die Ergebnisse zu wechseln.";
|
||||
"Opens the selected item" = "Öffnet den ausgewählten Eintrag";
|
||||
"Unpin recent file" = "Zuletzt verwendete Datei lösen";
|
||||
"Pin recent file" = "Zuletzt verwendete Datei anheften";
|
||||
"Keeps this file near the top of recent results" = "Hält diese Datei nahe am Anfang der letzten Ergebnisse";
|
||||
"Command Palette Results" = "Ergebnisse der Befehlspalette";
|
||||
"%lld results" = "%lld Ergebnisse";
|
||||
"Find in Files" = "In Dateien suchen";
|
||||
"Search project files" = "Projektdateien durchsuchen";
|
||||
"Find in Files Search" = "Suche in Dateien";
|
||||
"Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result." = "Gib Text ein, um Projektdateien zu durchsuchen. Nutze Pfeil hoch und runter, um durch die Ergebnisse zu wechseln. Drücke die Eingabetaste, um den ausgewählten Treffer zu öffnen.";
|
||||
"Search" = "Suchen";
|
||||
"Search Files" = "Dateien durchsuchen";
|
||||
"Case Sensitive Search" = "Suche mit Groß-/Kleinschreibung";
|
||||
"Open match in editor" = "Treffer im Editor öffnen";
|
||||
"Find in Files Results" = "Suchergebnisse in Dateien";
|
||||
"Sidebar" = "Seitenleiste";
|
||||
"Markdown Preview" = "Markdown-Vorschau";
|
||||
"markdown preview export summary" = "Exportzusammenfassung der Markdown-Vorschau";
|
||||
"Default" = "Standard";
|
||||
"Docs" = "Doku";
|
||||
"Article" = "Artikel";
|
||||
"Compact" = "Kompakt";
|
||||
"GitHub Docs" = "GitHub-Doku";
|
||||
"Academic Paper" = "Wissenschaftlicher Artikel";
|
||||
"Terminal Notes" = "Terminal-Notizen";
|
||||
"Magazine" = "Magazin";
|
||||
"Minimal Reader" = "Minimaler Leser";
|
||||
"Presentation" = "Präsentation";
|
||||
"Night Contrast" = "Nachtkontrast";
|
||||
"Warm Sepia" = "Warmes Sepia";
|
||||
"Dense Compact" = "Dicht kompakt";
|
||||
"Developer Spec" = "Entwickler-Spezifikation";
|
||||
"PDF Mode" = "PDF-Modus";
|
||||
"Paginated Fit" = "Paginiert angepasst";
|
||||
"One Page Fit" = "Eine Seite angepasst";
|
||||
"Export PDF" = "PDF exportieren";
|
||||
"Export Markdown preview as PDF" = "Markdown-Vorschau als PDF exportieren";
|
||||
"Share" = "Teilen";
|
||||
"Share Markdown preview HTML" = "HTML der Markdown-Vorschau teilen";
|
||||
"Copy HTML" = "HTML kopieren";
|
||||
"Copy Markdown preview HTML" = "HTML der Markdown-Vorschau kopieren";
|
||||
"Copy Markdown" = "Markdown kopieren";
|
||||
"Copy Markdown source" = "Markdown-Quelltext kopieren";
|
||||
"More" = "Mehr";
|
||||
"More Markdown preview actions" = "Weitere Aktionen für die Markdown-Vorschau";
|
||||
"How To Connect" = "So verbindest du dich";
|
||||
"Copied the broker attach code." = "Der Broker-Attach-Code wurde kopiert.";
|
||||
"On the Mac: enable Remote, open Connect, enter the SSH target server host, user, and port, optionally choose an SSH key, then press Connect Locally and Start Session. The SSH target server must be a real machine or service running an SSH server, not an iPhone or iPad simulator." = "Auf dem Mac: Remote aktivieren, Verbinden öffnen, Host, Benutzer und Port des SSH-Zielservers eingeben, optional einen SSH-Schlüssel auswählen und dann Lokal verbinden sowie Sitzung starten drücken. Der SSH-Zielserver muss ein echtes System oder ein Dienst mit laufendem SSH-Server sein, nicht ein iPhone- oder iPad-Simulator.";
|
||||
"If you use your local Mac as the SSH target with 127.0.0.1:22 and see 'connection refused', your Mac is not running an SSH server yet. Open System Settings > General > Sharing and enable Remote Login, then try Start Session again." = "Wenn du deinen lokalen Mac mit 127.0.0.1:22 als SSH-Ziel nutzt und „connection refused“ siehst, läuft auf deinem Mac noch kein SSH-Server. Öffne Systemeinstellungen > Allgemein > Freigaben und aktiviere Entfernte Anmeldung. Versuche danach Sitzung starten erneut.";
|
||||
"On iPhone or iPad: do not enter an SSH key. Copy the Attach Code from the active Mac broker, open Attach to Broker, paste the code, and attach." = "Auf iPhone oder iPad: keinen SSH-Schlüssel eingeben. Kopiere den Attach-Code aus der aktiven Mac-Broker-Sitzung, öffne Mit Broker verbinden, füge den Code ein und verbinde dich.";
|
||||
"After attaching: use Remote Browser to open a supported text file, edit it in the editor, and use Save to write it back to the same remote path through the Mac-hosted session. Save As stays local-only." = "Nach dem Verbinden: Nutze den Remote-Browser, um eine unterstützte Textdatei zu öffnen, sie im Editor zu bearbeiten und mit Speichern wieder in denselben Remote-Pfad über die Mac-gehostete Sitzung zurückzuschreiben. Sichern unter bleibt lokal.";
|
||||
"If the Mac-hosted broker session disappears while editing, Save will stop and the app will ask you to detach and reattach. Restart the session on the Mac first if needed, then use a fresh attach code." = "Wenn die Mac-gehostete Broker-Sitzung während der Bearbeitung verschwindet, stoppt Speichern und die App fordert zum Trennen und erneuten Verbinden auf. Starte die Sitzung auf dem Mac bei Bedarf zuerst neu und nutze dann einen frischen Attach-Code.";
|
||||
"Selected" = "Ausgewählt";
|
||||
"Use Saved Target" = "Gespeichertes Ziel verwenden";
|
||||
"Remove" = "Entfernen";
|
||||
"Connect…" = "Verbinden…";
|
||||
"Attach…" = "Verbinden…";
|
||||
"Start Session" = "Sitzung starten";
|
||||
"Stop Session" = "Sitzung stoppen";
|
||||
"Disconnect" = "Trennen";
|
||||
"Detach Broker" = "Broker trennen";
|
||||
"Remote Path" = "Remote-Pfad";
|
||||
"Refresh" = "Aktualisieren";
|
||||
"Up" = "Nach oben";
|
||||
"Home" = "Start";
|
||||
"Current Path:" = "Aktueller Pfad:";
|
||||
"Browse the active remote session on demand. Supported text files open into the editor and save explicitly back to the remote path." = "Durchsuche die aktive Remote-Sitzung bei Bedarf. Unterstützte Textdateien werden im Editor geöffnet und explizit zurück in den Remote-Pfad gespeichert.";
|
||||
"Retry Load" = "Erneut laden";
|
||||
"Reattach…" = "Erneut verbinden…";
|
||||
"Restart the remote session on the Mac first if it is no longer active, then attach again with a fresh code." = "Starte die Remote-Sitzung auf dem Mac zuerst neu, wenn sie nicht mehr aktiv ist, und verbinde dich dann mit einem frischen Code erneut.";
|
||||
"No remote entries loaded yet." = "Es wurden noch keine Remote-Einträge geladen.";
|
||||
"%@ is not a supported text file for remote editing." = "%@ ist keine unterstützte Textdatei für die Remote-Bearbeitung.";
|
||||
"Unsupported" = "Nicht unterstützt";
|
||||
"Open Remote" = "Remote öffnen";
|
||||
"Open remote folder %@" = "Remote-Ordner %@ öffnen";
|
||||
"Remote file %@" = "Remote-Datei %@";
|
||||
"Unsupported remote file %@" = "Nicht unterstützte Remote-Datei %@";
|
||||
"Loads the selected remote folder" = "Lädt den ausgewählten Remote-Ordner";
|
||||
"Opens the selected remote file in the editor for explicit remote save" = "Öffnet die ausgewählte Remote-Datei im Editor für explizites Remote-Speichern";
|
||||
"This remote file type is not supported for remote editing" = "Dieser Remote-Dateityp wird für Remote-Bearbeitung nicht unterstützt";
|
||||
"Remote Connect" = "Remote verbinden";
|
||||
"Connect stores and selects a remote target. On macOS, the Mac is the SSH owner: selecting an SSH key enables the Mac to perform the explicit SSH login only when you start a session. The target must be a real SSH server. Once connected, the Mac can publish an attach code so iPhone and iPad can browse, open, edit, and explicitly save supported text files through that brokered session." = "Verbinden speichert und wählt ein Remote-Ziel aus. Unter macOS ist der Mac der SSH-Besitzer: Wenn ein SSH-Schlüssel gewählt ist, führt der Mac den expliziten SSH-Login erst beim Start einer Sitzung aus. Das Ziel muss ein echter SSH-Server sein. Nach der Verbindung kann der Mac einen Attach-Code veröffentlichen, damit iPhone und iPad unterstützte Textdateien über diese Broker-Sitzung durchsuchen, öffnen, bearbeiten und explizit speichern können.";
|
||||
"Nickname" = "Name";
|
||||
"Host" = "Host";
|
||||
"User" = "Benutzer";
|
||||
"Port" = "Port";
|
||||
"Port range: 1-65535. Port 22 is the standard SSH port." = "Portbereich: 1-65535. Port 22 ist der Standard-SSH-Port.";
|
||||
"Choose SSH Key…" = "SSH-Schlüssel auswählen…";
|
||||
"Change SSH Key…" = "SSH-Schlüssel ändern…";
|
||||
"Clear Key" = "Schlüssel entfernen";
|
||||
"No SSH key selected. Without a key, Start Session falls back to a TCP connection test from the Mac to the target host." = "Es ist kein SSH-Schlüssel ausgewählt. Ohne Schlüssel fällt Sitzung starten auf einen TCP-Verbindungstest vom Mac zum Zielhost zurück.";
|
||||
"Selected key:" = "Ausgewählter Schlüssel:";
|
||||
"SSH-key login is currently available on macOS only. iPhone and iPad attach to the active Mac broker instead of handling SSH keys or direct SSH connections." = "SSH-Schlüssel-Anmeldung ist derzeit nur unter macOS verfügbar. iPhone und iPad verbinden sich stattdessen mit dem aktiven Mac-Broker, statt SSH-Schlüssel oder direkte SSH-Verbindungen zu verwalten.";
|
||||
"Connect Locally" = "Lokal verbinden";
|
||||
"Attach to Broker" = "Mit Broker verbinden";
|
||||
"Paste the attach code from the active macOS broker session. This device does not use its own SSH key. After attaching, it browses, opens, edits, and explicitly saves supported text files through the Mac-hosted broker." = "Füge den Attach-Code aus der aktiven macOS-Broker-Sitzung ein. Dieses Gerät verwendet keinen eigenen SSH-Schlüssel. Nach dem Verbinden durchsucht, öffnet, bearbeitet und speichert es unterstützte Textdateien explizit über den Mac-gehosteten Broker.";
|
||||
"Attach Code" = "Attach-Code";
|
||||
"No folder selected" = "Kein Ordner ausgewählt";
|
||||
"Folder is empty" = "Ordner ist leer";
|
||||
"Project Structure" = "Projektstruktur";
|
||||
|
|
@ -172,6 +281,10 @@
|
|||
"Blue" = "Blau";
|
||||
"Dark Gray" = "Dunkelgrau";
|
||||
"Black" = "Schwarz";
|
||||
"Remote" = "Remote";
|
||||
"Choose how windows open and how appearance is applied." = "Lege fest, wie Fenster geöffnet werden und wie die Darstellung angewendet wird.";
|
||||
"Show Font List" = "Schriftliste anzeigen";
|
||||
"Hide Font List" = "Schriftliste ausblenden";
|
||||
"Display, indentation, editing behavior, and completion sources." = "Anzeige, Einrückung, Bearbeitungsverhalten und Vervollständigungsquellen.";
|
||||
"Section" = "Bereich";
|
||||
"Basics" = "Grundlagen";
|
||||
|
|
|
|||
|
|
@ -130,6 +130,115 @@
|
|||
"Case Sensitive" = "Case Sensitive";
|
||||
"Use Regex" = "Use Regex";
|
||||
"Quick Open" = "Quick Open";
|
||||
"Find Next" = "Find Next";
|
||||
"Jump to Match" = "Jump to Match";
|
||||
"Search text" = "Search text";
|
||||
"Replacement" = "Replacement";
|
||||
"%lld match" = "%lld match";
|
||||
"%lld matches" = "%lld matches";
|
||||
"Matches: %@" = "Matches: %@";
|
||||
"No Matches Found" = "No Matches Found";
|
||||
"Command Palette" = "Command Palette";
|
||||
"Search commands, files, and tabs" = "Search commands, files, and tabs";
|
||||
"Command Palette Search" = "Command Palette Search";
|
||||
"Type to search commands, files, and tabs. Use Up and Down Arrow to move through results." = "Type to search commands, files, and tabs. Use Up and Down Arrow to move through results.";
|
||||
"Opens the selected item" = "Opens the selected item";
|
||||
"Unpin recent file" = "Unpin recent file";
|
||||
"Pin recent file" = "Pin recent file";
|
||||
"Keeps this file near the top of recent results" = "Keeps this file near the top of recent results";
|
||||
"Command Palette Results" = "Command Palette Results";
|
||||
"%lld results" = "%lld results";
|
||||
"Find in Files" = "Find in Files";
|
||||
"Search project files" = "Search project files";
|
||||
"Find in Files Search" = "Find in Files Search";
|
||||
"Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result." = "Enter text to search across project files. Use Up and Down Arrow to move through results. Press Return to open the selected result.";
|
||||
"Search" = "Search";
|
||||
"Search Files" = "Search Files";
|
||||
"Case Sensitive Search" = "Case Sensitive Search";
|
||||
"Open match in editor" = "Open match in editor";
|
||||
"Find in Files Results" = "Find in Files Results";
|
||||
"Sidebar" = "Sidebar";
|
||||
"Markdown Preview" = "Markdown Preview";
|
||||
"markdown preview export summary" = "markdown preview export summary";
|
||||
"Default" = "Default";
|
||||
"Docs" = "Docs";
|
||||
"Article" = "Article";
|
||||
"Compact" = "Compact";
|
||||
"GitHub Docs" = "GitHub Docs";
|
||||
"Academic Paper" = "Academic Paper";
|
||||
"Terminal Notes" = "Terminal Notes";
|
||||
"Magazine" = "Magazine";
|
||||
"Minimal Reader" = "Minimal Reader";
|
||||
"Presentation" = "Presentation";
|
||||
"Night Contrast" = "Night Contrast";
|
||||
"Warm Sepia" = "Warm Sepia";
|
||||
"Dense Compact" = "Dense Compact";
|
||||
"Developer Spec" = "Developer Spec";
|
||||
"PDF Mode" = "PDF Mode";
|
||||
"Paginated Fit" = "Paginated Fit";
|
||||
"One Page Fit" = "One Page Fit";
|
||||
"Export PDF" = "Export PDF";
|
||||
"Export Markdown preview as PDF" = "Export Markdown preview as PDF";
|
||||
"Share" = "Share";
|
||||
"Share Markdown preview HTML" = "Share Markdown preview HTML";
|
||||
"Copy HTML" = "Copy HTML";
|
||||
"Copy Markdown preview HTML" = "Copy Markdown preview HTML";
|
||||
"Copy Markdown" = "Copy Markdown";
|
||||
"Copy Markdown source" = "Copy Markdown source";
|
||||
"More" = "More";
|
||||
"More Markdown preview actions" = "More Markdown preview actions";
|
||||
"How To Connect" = "How To Connect";
|
||||
"Copied the broker attach code." = "Copied the broker attach code.";
|
||||
"On the Mac: enable Remote, open Connect, enter the SSH target server host, user, and port, optionally choose an SSH key, then press Connect Locally and Start Session. The SSH target server must be a real machine or service running an SSH server, not an iPhone or iPad simulator." = "On the Mac: enable Remote, open Connect, enter the SSH target server host, user, and port, optionally choose an SSH key, then press Connect Locally and Start Session. The SSH target server must be a real machine or service running an SSH server, not an iPhone or iPad simulator.";
|
||||
"If you use your local Mac as the SSH target with 127.0.0.1:22 and see 'connection refused', your Mac is not running an SSH server yet. Open System Settings > General > Sharing and enable Remote Login, then try Start Session again." = "If you use your local Mac as the SSH target with 127.0.0.1:22 and see 'connection refused', your Mac is not running an SSH server yet. Open System Settings > General > Sharing and enable Remote Login, then try Start Session again.";
|
||||
"On iPhone or iPad: do not enter an SSH key. Copy the Attach Code from the active Mac broker, open Attach to Broker, paste the code, and attach." = "On iPhone or iPad: do not enter an SSH key. Copy the Attach Code from the active Mac broker, open Attach to Broker, paste the code, and attach.";
|
||||
"After attaching: use Remote Browser to open a supported text file, edit it in the editor, and use Save to write it back to the same remote path through the Mac-hosted session. Save As stays local-only." = "After attaching: use Remote Browser to open a supported text file, edit it in the editor, and use Save to write it back to the same remote path through the Mac-hosted session. Save As stays local-only.";
|
||||
"If the Mac-hosted broker session disappears while editing, Save will stop and the app will ask you to detach and reattach. Restart the session on the Mac first if needed, then use a fresh attach code." = "If the Mac-hosted broker session disappears while editing, Save will stop and the app will ask you to detach and reattach. Restart the session on the Mac first if needed, then use a fresh attach code.";
|
||||
"Selected" = "Selected";
|
||||
"Use Saved Target" = "Use Saved Target";
|
||||
"Remove" = "Remove";
|
||||
"Connect…" = "Connect…";
|
||||
"Attach…" = "Attach…";
|
||||
"Start Session" = "Start Session";
|
||||
"Stop Session" = "Stop Session";
|
||||
"Disconnect" = "Disconnect";
|
||||
"Detach Broker" = "Detach Broker";
|
||||
"Remote Path" = "Remote Path";
|
||||
"Refresh" = "Refresh";
|
||||
"Up" = "Up";
|
||||
"Home" = "Home";
|
||||
"Current Path:" = "Current Path:";
|
||||
"Browse the active remote session on demand. Supported text files open into the editor and save explicitly back to the remote path." = "Browse the active remote session on demand. Supported text files open into the editor and save explicitly back to the remote path.";
|
||||
"Retry Load" = "Retry Load";
|
||||
"Reattach…" = "Reattach…";
|
||||
"Restart the remote session on the Mac first if it is no longer active, then attach again with a fresh code." = "Restart the remote session on the Mac first if it is no longer active, then attach again with a fresh code.";
|
||||
"No remote entries loaded yet." = "No remote entries loaded yet.";
|
||||
"%@ is not a supported text file for remote editing." = "%@ is not a supported text file for remote editing.";
|
||||
"Unsupported" = "Unsupported";
|
||||
"Open Remote" = "Open Remote";
|
||||
"Open remote folder %@" = "Open remote folder %@";
|
||||
"Remote file %@" = "Remote file %@";
|
||||
"Unsupported remote file %@" = "Unsupported remote file %@";
|
||||
"Loads the selected remote folder" = "Loads the selected remote folder";
|
||||
"Opens the selected remote file in the editor for explicit remote save" = "Opens the selected remote file in the editor for explicit remote save";
|
||||
"This remote file type is not supported for remote editing" = "This remote file type is not supported for remote editing";
|
||||
"Remote Connect" = "Remote Connect";
|
||||
"Connect stores and selects a remote target. On macOS, the Mac is the SSH owner: selecting an SSH key enables the Mac to perform the explicit SSH login only when you start a session. The target must be a real SSH server. Once connected, the Mac can publish an attach code so iPhone and iPad can browse, open, edit, and explicitly save supported text files through that brokered session." = "Connect stores and selects a remote target. On macOS, the Mac is the SSH owner: selecting an SSH key enables the Mac to perform the explicit SSH login only when you start a session. The target must be a real SSH server. Once connected, the Mac can publish an attach code so iPhone and iPad can browse, open, edit, and explicitly save supported text files through that brokered session.";
|
||||
"Nickname" = "Nickname";
|
||||
"Host" = "Host";
|
||||
"User" = "User";
|
||||
"Port" = "Port";
|
||||
"Port range: 1-65535. Port 22 is the standard SSH port." = "Port range: 1-65535. Port 22 is the standard SSH port.";
|
||||
"Choose SSH Key…" = "Choose SSH Key…";
|
||||
"Change SSH Key…" = "Change SSH Key…";
|
||||
"Clear Key" = "Clear Key";
|
||||
"No SSH key selected. Without a key, Start Session falls back to a TCP connection test from the Mac to the target host." = "No SSH key selected. Without a key, Start Session falls back to a TCP connection test from the Mac to the target host.";
|
||||
"Selected key:" = "Selected key:";
|
||||
"SSH-key login is currently available on macOS only. iPhone and iPad attach to the active Mac broker instead of handling SSH keys or direct SSH connections." = "SSH-key login is currently available on macOS only. iPhone and iPad attach to the active Mac broker instead of handling SSH keys or direct SSH connections.";
|
||||
"Connect Locally" = "Connect Locally";
|
||||
"Attach to Broker" = "Attach to Broker";
|
||||
"Paste the attach code from the active macOS broker session. This device does not use its own SSH key. After attaching, it browses, opens, edits, and explicitly saves supported text files through the Mac-hosted broker." = "Paste the attach code from the active macOS broker session. This device does not use its own SSH key. After attaching, it browses, opens, edits, and explicitly saves supported text files through the Mac-hosted broker.";
|
||||
"Attach Code" = "Attach Code";
|
||||
"No folder selected" = "No folder selected";
|
||||
"Folder is empty" = "Folder is empty";
|
||||
"Project Structure" = "Project Structure";
|
||||
|
|
@ -151,6 +260,10 @@
|
|||
"Blue" = "Blue";
|
||||
"Dark Gray" = "Dark Gray";
|
||||
"Black" = "Black";
|
||||
"Remote" = "Remote";
|
||||
"Choose how windows open and how appearance is applied." = "Choose how windows open and how appearance is applied.";
|
||||
"Show Font List" = "Show Font List";
|
||||
"Hide Font List" = "Hide Font List";
|
||||
"Display, indentation, editing behavior, and completion sources." = "Display, indentation, editing behavior, and completion sources.";
|
||||
"Section" = "Section";
|
||||
"Basics" = "Basics";
|
||||
|
|
|
|||
Loading…
Reference in a new issue