diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3bacc..0241ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to **Neon Vision Editor** are documented in this file. The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags. +## [v0.4.30] - 2026-02-24 + +### Added +- TODO + +### Improved +- TODO + +### Fixed +- TODO + ## [v0.4.29] - 2026-02-23 ### Added diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 2a469c8..6e81a36 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -361,7 +361,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 329; + CURRENT_PROJECT_VERSION = 330; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -442,7 +442,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 329; + CURRENT_PROJECT_VERSION = 330; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/App/NeonVisionEditorApp.swift b/Neon Vision Editor/App/NeonVisionEditorApp.swift index 86db961..ff6bde5 100644 --- a/Neon Vision Editor/App/NeonVisionEditorApp.swift +++ b/Neon Vision Editor/App/NeonVisionEditorApp.swift @@ -90,6 +90,8 @@ struct NeonVisionEditorApp: App { @State private var useAppleIntelligence: Bool = true @State private var appleAIStatus: String = "Apple Intelligence: Checking…" @State private var appleAIRoundTripMS: Double? = nil + @State private var settingsShortcutMonitorInstalled = false + @State private var settingsShortcutMonitorToken: Any? @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate #endif @State private var showGrokError: Bool = false @@ -262,6 +264,7 @@ struct NeonVisionEditorApp: App { .environmentObject(supportPurchaseManager) .environmentObject(appUpdateManager) .onAppear { + installSettingsShortcutMonitorIfNeeded() appDelegate.viewModel = viewModel appDelegate.appUpdateManager = appUpdateManager } @@ -326,6 +329,30 @@ struct NeonVisionEditorApp: App { .preferredColorScheme(preferredAppearance) } + MenuBarExtra("Welcome Tour", systemImage: "sparkles.rectangle.stack") { + Button { + postWindowCommand(.showWelcomeTourRequested) + } label: { + Label("Show Welcome Tour", systemImage: "sparkles.rectangle.stack") + } + + Divider() + + Button { + showSettingsWindow() + } label: { + Label("Settings…", systemImage: "gearshape") + } + + if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution { + Button { + postWindowCommand(.showUpdaterRequested, object: true) + } label: { + Label("Check for Updates…", systemImage: "arrow.triangle.2.circlepath.circle") + } + } + } + .commands { CommandGroup(replacing: .appSettings) { if ReleaseRuntimePolicy.isUpdaterEnabledForCurrentDistribution { @@ -337,7 +364,7 @@ struct NeonVisionEditorApp: App { Divider() Button("Settings…") { - postWindowCommand(.showSettingsRequested) + showSettingsWindow() } .keyboardShortcut(",", modifiers: .command) } @@ -607,11 +634,38 @@ struct NeonVisionEditorApp: App { private func showSettingsWindow() { #if os(macOS) + NSApp.activate(ignoringOtherApps: true) if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { - _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + if !NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) { + postWindowCommand(.showSettingsRequested) + } } #endif } + +#if os(macOS) + private func installSettingsShortcutMonitorIfNeeded() { + guard !settingsShortcutMonitorInstalled else { return } + settingsShortcutMonitorInstalled = true + settingsShortcutMonitorToken = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) else { + return event + } + let chars = event.characters ?? "" + let charsIgnoringModifiers = event.charactersIgnoringModifiers ?? "" + if chars == "+" + || chars == "=" + || chars == "," + || charsIgnoringModifiers == "+" + || charsIgnoringModifiers == "=" + || charsIgnoringModifiers == "," { + showSettingsWindow() + return nil + } + return event + } + } +#endif } struct ShowGrokErrorKey: EnvironmentKey { diff --git a/Neon Vision Editor/UI/ContentView+Toolbar.swift b/Neon Vision Editor/UI/ContentView+Toolbar.swift index cc496f5..39e4766 100644 --- a/Neon Vision Editor/UI/ContentView+Toolbar.swift +++ b/Neon Vision Editor/UI/ContentView+Toolbar.swift @@ -10,6 +10,31 @@ extension ContentView { activeProviderName.components(separatedBy: " (").first ?? activeProviderName } + private var providerBadgeLabelText: String { +#if os(macOS) + if compactActiveProviderName == "Apple" { + return "AI Provider \(compactActiveProviderName)" + } +#endif + return compactActiveProviderName + } + + private var providerBadgeIsAppleCompletionActive: Bool { + compactActiveProviderName == "Apple" && isAutoCompletionEnabled + } + + private var providerBadgeForegroundColor: Color { + providerBadgeIsAppleCompletionActive ? .green : .secondary + } + + private var providerBadgeBackgroundColor: Color { + providerBadgeIsAppleCompletionActive ? Color.green.opacity(0.16) : Color.secondary.opacity(0.12) + } + + private var providerBadgeTooltip: String { + "AI Provider for Code Completion" + } + #if os(iOS) private var iOSToolbarChromeStyle: GlassChromeStyle { .single } private var iOSToolbarTintColor: Color { @@ -248,17 +273,17 @@ extension ContentView { @ViewBuilder private var activeProviderBadgeControl: some View { - Text(compactActiveProviderName) + Text(providerBadgeLabelText) .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(providerBadgeForegroundColor) .lineLimit(1) .truncationMode(.tail) .minimumScaleFactor(0.9) .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12), in: Capsule()) - .help("Active provider") + .background(providerBadgeBackgroundColor, in: Capsule()) + .help(providerBadgeTooltip) } @ViewBuilder @@ -816,18 +841,18 @@ extension ContentView { .frame(width: 140) .padding(.vertical, 2) - Text(compactActiveProviderName) + Text(providerBadgeLabelText) .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(providerBadgeForegroundColor) .lineLimit(1) .truncationMode(.tail) .minimumScaleFactor(0.9) .fixedSize(horizontal: true, vertical: false) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12), in: Capsule()) + .background(providerBadgeBackgroundColor, in: Capsule()) .padding(.leading, 6) - .help("Active provider") + .help(providerBadgeTooltip) Button(action: { openSettings() @@ -837,6 +862,30 @@ extension ContentView { } .help("Settings") + #if os(macOS) + Button(action: { + showMarkdownPreviewPane.toggle() + }) { + Label("Markdown Preview", systemImage: showMarkdownPreviewPane ? "doc.richtext.fill" : "doc.richtext") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .disabled(currentLanguage != "markdown") + .help("Toggle Markdown Preview") + + if showMarkdownPreviewPane && currentLanguage == "markdown" { + Menu { + Button("Default") { markdownPreviewTemplateRaw = "default" } + Button("Docs") { markdownPreviewTemplateRaw = "docs" } + Button("Article") { markdownPreviewTemplateRaw = "article" } + Button("Compact") { markdownPreviewTemplateRaw = "compact" } + } label: { + Label("Preview Style", systemImage: "textformat.size") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .help("Markdown Preview Template") + } + #endif + Button(action: { undoFromToolbar() }) { Label("Undo", systemImage: "arrow.uturn.backward") .foregroundStyle(NeonUIStyle.accentBlue) @@ -854,6 +903,37 @@ extension ContentView { .help("Check for Updates") } + Button(action: { openFileFromToolbar() }) { + Label("Open", systemImage: "folder") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .help("Open File… (Cmd+O)") + + Button(action: { + saveCurrentTabFromToolbar() + }) { + Label("Save", systemImage: "square.and.arrow.down") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .disabled(viewModel.selectedTab == nil) + .help("Save File (Cmd+S)") + + Button(action: { viewModel.addNewTab() }) { + Label("New Tab", systemImage: "plus.square.on.square") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .help("New Tab (Cmd+T)") + + #if os(macOS) + Button(action: { + openWindow(id: "blank-window") + }) { + Label("New Window", systemImage: "macwindow.badge.plus") + .foregroundStyle(NeonUIStyle.accentBlue) + } + .help("New Window (Cmd+N)") + #endif + Button(action: { adjustEditorFontSize(-1) }) { Label("Font -", systemImage: "textformat.size.smaller") .foregroundStyle(NeonUIStyle.accentBlue) @@ -882,37 +962,6 @@ extension ContentView { } .help("Insert Template for Current Language") - Button(action: { openFileFromToolbar() }) { - Label("Open", systemImage: "folder") - .foregroundStyle(NeonUIStyle.accentBlue) - } - .help("Open File… (Cmd+O)") - - Button(action: { viewModel.addNewTab() }) { - Label("New Tab", systemImage: "plus.square.on.square") - .foregroundStyle(NeonUIStyle.accentBlue) - } - .help("New Tab (Cmd+T)") - - #if os(macOS) - Button(action: { - openWindow(id: "blank-window") - }) { - Label("New Window", systemImage: "macwindow.badge.plus") - .foregroundStyle(NeonUIStyle.accentBlue) - } - .help("New Window (Cmd+N)") - #endif - - Button(action: { - saveCurrentTabFromToolbar() - }) { - Label("Save", systemImage: "square.and.arrow.down") - .foregroundStyle(NeonUIStyle.accentBlue) - } - .disabled(viewModel.selectedTab == nil) - .help("Save File (Cmd+S)") - Button(action: { toggleSidebarFromToolbar() }) { diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index a04c2f8..3452720 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -250,6 +250,8 @@ struct ContentView: View { #if os(macOS) @State private var hostWindowNumber: Int? = nil @AppStorage("ShowBracketHelperBarMac") var showBracketHelperBarMac: Bool = false + @State var showMarkdownPreviewPane: Bool = false + @AppStorage("MarkdownPreviewTemplateMac") var markdownPreviewTemplateRaw: String = "default" @State private var windowCloseConfirmationDelegate: WindowCloseConfirmationDelegate? = nil #endif @State private var showLanguageSetupPrompt: Bool = false @@ -2835,6 +2837,14 @@ struct ContentView: View { alignment: brainDumpLayoutEnabled ? .top : .topLeading ) +#if os(macOS) + if showMarkdownPreviewPane && currentLanguage == "markdown" && !brainDumpLayoutEnabled { + Divider() + markdownPreviewPane + .frame(minWidth: 280, idealWidth: 420, maxWidth: 680, maxHeight: .infinity) + } +#endif + if showProjectStructureSidebar && !brainDumpLayoutEnabled { #if os(macOS) VStack(spacing: 0) { @@ -2966,6 +2976,13 @@ struct ContentView: View { .padding(.trailing, 12) } } +#if os(macOS) + .onChange(of: currentLanguage) { _, newLanguage in + if newLanguage != "markdown", showMarkdownPreviewPane { + showMarkdownPreviewPane = false + } + } +#endif #if os(macOS) .toolbarBackground( macChromeBackgroundStyle, @@ -2983,6 +3000,444 @@ struct ContentView: View { #endif } +#if os(macOS) + @ViewBuilder + private var markdownPreviewPane: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Markdown Preview") + .font(.headline) + Spacer() + Picker("Template", selection: $markdownPreviewTemplateRaw) { + Text("Default").tag("default") + Text("Docs").tag("docs") + Text("Article").tag("article") + Text("Compact").tag("compact") + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: 120) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(editorSurfaceBackgroundStyle) + + MarkdownPreviewWebView(html: markdownPreviewHTML(from: currentContent)) + .accessibilityLabel("Markdown Preview Content") + } + .background(editorSurfaceBackgroundStyle) + } + + private var markdownPreviewTemplate: String { + switch markdownPreviewTemplateRaw { + case "docs", "article", "compact": + return markdownPreviewTemplateRaw + default: + return "default" + } + } + + private func markdownPreviewHTML(from markdownText: String) -> String { + let bodyHTML = renderedMarkdownBodyHTML(from: markdownText) ?? "
\(escapedHTML(markdownText))
" + return """ + + + + + + + + +
+ \(bodyHTML) +
+ + + """ + } + + private func renderedMarkdownBodyHTML(from markdownText: String) -> String? { + let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines) + return html.isEmpty ? nil : html + } + + private func simpleMarkdownToHTML(_ markdown: String) -> String { + let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + var result: [String] = [] + var paragraphLines: [String] = [] + var insideCodeFence = false + var codeFenceLanguage: String? + var insideUnorderedList = false + var insideOrderedList = false + var insideBlockquote = false + + func flushParagraph() { + guard !paragraphLines.isEmpty else { return } + let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "
") + result.append("

\(paragraph)

") + paragraphLines.removeAll(keepingCapacity: true) + } + + func closeLists() { + if insideUnorderedList { + result.append("") + insideUnorderedList = false + } + if insideOrderedList { + result.append("") + insideOrderedList = false + } + } + + func closeBlockquote() { + if insideBlockquote { + flushParagraph() + closeLists() + result.append("") + insideBlockquote = false + } + } + + func closeParagraphAndInlineContainers() { + flushParagraph() + closeLists() + } + + for rawLine in lines { + let line = rawLine + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + if insideCodeFence { + result.append("") + insideCodeFence = false + codeFenceLanguage = nil + } else { + closeBlockquote() + closeParagraphAndInlineContainers() + insideCodeFence = true + let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + codeFenceLanguage = lang.isEmpty ? nil : lang + if let codeFenceLanguage { + result.append("
")
+                    } else {
+                        result.append("
")
+                    }
+                }
+                continue
+            }
+
+            if insideCodeFence {
+                result.append("\(escapedHTML(line))\n")
+                continue
+            }
+
+            if trimmed.isEmpty {
+                closeParagraphAndInlineContainers()
+                closeBlockquote()
+                continue
+            }
+
+            if let heading = markdownHeading(from: trimmed) {
+                closeBlockquote()
+                closeParagraphAndInlineContainers()
+                result.append("\(inlineMarkdownToHTML(heading.text))")
+                continue
+            }
+
+            if isMarkdownHorizontalRule(trimmed) {
+                closeBlockquote()
+                closeParagraphAndInlineContainers()
+                result.append("
") + continue + } + + var workingLine = trimmed + let isBlockquoteLine = workingLine.hasPrefix(">") + if isBlockquoteLine { + if !insideBlockquote { + closeParagraphAndInlineContainers() + result.append("
") + insideBlockquote = true + } + workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces) + } else { + closeBlockquote() + } + + if let unordered = markdownUnorderedListItem(from: workingLine) { + flushParagraph() + if insideOrderedList { + result.append("") + insideOrderedList = false + } + if !insideUnorderedList { + result.append("
    ") + insideUnorderedList = true + } + result.append("
  • \(inlineMarkdownToHTML(unordered))
  • ") + continue + } + + if let ordered = markdownOrderedListItem(from: workingLine) { + flushParagraph() + if insideUnorderedList { + result.append("
") + insideUnorderedList = false + } + if !insideOrderedList { + result.append("
    ") + insideOrderedList = true + } + result.append("
  1. \(inlineMarkdownToHTML(ordered))
  2. ") + continue + } + + closeLists() + paragraphLines.append(workingLine) + } + + closeBlockquote() + closeParagraphAndInlineContainers() + if insideCodeFence { + result.append("
") + } + return result.joined(separator: "\n") + } + + private func markdownHeading(from line: String) -> (level: Int, text: String)? { + let pattern = "^(#{1,6})\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let hashesRange = Range(match.range(at: 1), in: line), + let textRange = Range(match.range(at: 2), in: line) else { + return nil + } + return (line[hashesRange].count, String(line[textRange])) + } + + private func isMarkdownHorizontalRule(_ line: String) -> Bool { + let compact = line.replacingOccurrences(of: " ", with: "") + return compact == "***" || compact == "---" || compact == "___" + } + + private func markdownUnorderedListItem(from line: String) -> String? { + let pattern = "^[-*+]\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let textRange = Range(match.range(at: 1), in: line) else { + return nil + } + return String(line[textRange]) + } + + private func markdownOrderedListItem(from line: String) -> String? { + let pattern = "^\\d+[\\.)]\\s+(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, options: [], range: range), + let textRange = Range(match.range(at: 1), in: line) else { + return nil + } + return String(line[textRange]) + } + + private func inlineMarkdownToHTML(_ text: String) -> String { + var html = escapedHTML(text) + var codeSpans: [String] = [] + + html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in + let content = String(match.dropFirst().dropLast()) + let token = "__CODE_SPAN_\(codeSpans.count)__" + codeSpans.append("\(content)") + return token + } + + html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in + let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") + guard parts.count == 2 else { return match } + return "\"\(parts[0])\"/" + } + + html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in + let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") + guard parts.count == 2 else { return match } + return "\(parts[0])" + } + + html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "\(String($0.dropFirst(2).dropLast(2)))" } + html = replacingRegex(in: html, pattern: "__([^_]+)__") { "\(String($0.dropFirst(2).dropLast(2)))" } + html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "\(String($0.dropFirst().dropLast()))" } + html = replacingRegex(in: html, pattern: "_([^_]+)_") { "\(String($0.dropFirst().dropLast()))" } + + for (index, codeHTML) in codeSpans.enumerated() { + html = html.replacingOccurrences(of: "__CODE_SPAN_\(index)__", with: codeHTML) + } + return html + } + + private func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + guard !matches.isEmpty else { return text } + + var output = text + for match in matches.reversed() { + guard let range = Range(match.range, in: output) else { continue } + let segment = String(output[range]) + output.replaceSubrange(range, with: transform(segment)) + } + return output + } + + private func captureGroups(in text: String, pattern: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else { + return [] + } + var groups: [String] = [] + for idx in 1.. String { + let basePadding: String + let fontSize: String + let lineHeight: String + let maxWidth: String + switch template { + case "docs": + basePadding = "22px 30px" + fontSize = "15px" + lineHeight = "1.7" + maxWidth = "900px" + case "article": + basePadding = "32px 48px" + fontSize = "17px" + lineHeight = "1.8" + maxWidth = "760px" + case "compact": + basePadding = "14px 16px" + fontSize = "13px" + lineHeight = "1.5" + maxWidth = "none" + default: + basePadding = "18px 22px" + fontSize = "14px" + lineHeight = "1.6" + maxWidth = "860px" + } + + return """ + :root { color-scheme: light dark; } + html, body { + margin: 0; + padding: 0; + background: transparent; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + font-size: \(fontSize); + line-height: \(lineHeight); + } + .content { + max-width: \(maxWidth); + padding: \(basePadding); + margin: 0 auto; + } + h1, h2, h3, h4, h5, h6 { + line-height: 1.25; + margin: 1.1em 0 0.55em; + font-weight: 700; + } + h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; } + h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; } + h3 { font-size: 1.2em; } + p, ul, ol, blockquote, table, pre { margin: 0.65em 0; } + ul, ol { padding-left: 1.3em; } + li { margin: 0.2em 0; } + blockquote { + margin-left: 0; + padding: 0.45em 0.9em; + border-left: 3px solid color-mix(in srgb, currentColor 30%, transparent); + background: color-mix(in srgb, currentColor 6%, transparent); + border-radius: 6px; + } + code { + font-family: "SF Mono", "Menlo", "Monaco", monospace; + font-size: 0.9em; + padding: 0.12em 0.35em; + border-radius: 5px; + background: color-mix(in srgb, currentColor 10%, transparent); + } + pre { + overflow-x: auto; + padding: 0.8em 0.95em; + border-radius: 9px; + background: color-mix(in srgb, currentColor 8%, transparent); + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + line-height: 1.35; + white-space: pre; + } + pre code { + display: block; + padding: 0; + background: transparent; + border-radius: 0; + font-size: 0.88em; + line-height: 1.35; + white-space: pre; + } + table { + border-collapse: collapse; + width: 100%; + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 8px; + overflow: hidden; + } + th, td { + text-align: left; + padding: 0.45em 0.55em; + border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); + } + th { + background: color-mix(in srgb, currentColor 7%, transparent); + font-weight: 600; + } + a { + color: #2f7cf6; + text-decoration: none; + border-bottom: 1px solid color-mix(in srgb, #2f7cf6 45%, transparent); + } + img { + max-width: 100%; + height: auto; + border-radius: 8px; + } + hr { + border: 0; + border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent); + margin: 1.1em 0; + } + """ + } + + private func escapedHTML(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +#endif + #if os(iOS) @ViewBuilder private var iPhoneUnifiedTopChromeHost: some View { diff --git a/Neon Vision Editor/UI/EditorTextView.swift b/Neon Vision Editor/UI/EditorTextView.swift index 61f4f33..c422170 100644 --- a/Neon Vision Editor/UI/EditorTextView.swift +++ b/Neon Vision Editor/UI/EditorTextView.swift @@ -16,6 +16,21 @@ private enum EditorRuntimeLimits { static let bindingDebounceDelay: TimeInterval = 0.18 } +#if os(macOS) +private func replaceTextPreservingSelectionAndFocus(_ textView: NSTextView, with newText: String) { + let previousSelection = textView.selectedRange() + let hadFocus = (textView.window?.firstResponder as? NSTextView) === textView + textView.string = newText + let length = (newText as NSString).length + let safeLocation = min(max(0, previousSelection.location), length) + let safeLength = min(max(0, previousSelection.length), max(0, length - safeLocation)) + textView.setSelectedRange(NSRange(location: safeLocation, length: safeLength)) + if hadFocus { + textView.window?.makeFirstResponder(textView) + } +} +#endif + private enum EmmetExpander { struct Node { var tag: String @@ -1141,8 +1156,8 @@ final class AcceptingTextView: NSTextView { } return } - // After paste, jump back to the first line. - pendingPasteCaretLocation = 0 + // Keep caret anchored at the current insertion location while paste async work settles. + pendingPasteCaretLocation = selectedRange().location if let raw = pasteboardPlainString(from: pasteboard), !raw.isEmpty { if let pathURL = fileURLFromString(raw) { @@ -1872,12 +1887,18 @@ struct CustomTextEditor: NSViewRepresentable { // Sanitize and avoid publishing binding during update let target = sanitizedForExternalSet(text) if textView.string != target { - context.coordinator.cancelPendingBindingSync() - textView.string = target - context.coordinator.invalidateHighlightCache() - DispatchQueue.main.async { - if self.text != target { - self.text = target + let hasFocus = (textView.window?.firstResponder as? NSTextView) === textView + let shouldPreferEditorBuffer = hasFocus && !isTabLoadingContent + if shouldPreferEditorBuffer { + context.coordinator.syncBindingTextImmediately(textView.string) + } else { + context.coordinator.cancelPendingBindingSync() + replaceTextPreservingSelectionAndFocus(textView, with: target) + context.coordinator.invalidateHighlightCache() + DispatchQueue.main.async { + if self.text != target { + self.text = target + } } } } @@ -1911,7 +1932,7 @@ struct CustomTextEditor: NSViewRepresentable { if currentLength <= 300_000 { let sanitized = AcceptingTextView.sanitizePlainText(textView.string) if sanitized != textView.string { - textView.string = sanitized + replaceTextPreservingSelectionAndFocus(textView, with: sanitized) context.coordinator.invalidateHighlightCache() DispatchQueue.main.async { if self.text != sanitized { @@ -2031,6 +2052,7 @@ struct CustomTextEditor: NSViewRepresentable { private var pendingEditedRange: NSRange? private var pendingBindingSync: DispatchWorkItem? var lastAppliedWrapMode: Bool? + var hasPendingBindingSync: Bool { pendingBindingSync != nil } init(_ parent: CustomTextEditor) { self.parent = parent @@ -2057,12 +2079,14 @@ struct CustomTextEditor: NSViewRepresentable { return } pendingBindingSync?.cancel() + pendingBindingSync = nil if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length { parent.text = text return } let work = DispatchWorkItem { [weak self] in guard let self else { return } + self.pendingBindingSync = nil if self.textView?.string == text { self.parent.text = text } @@ -2073,6 +2097,11 @@ struct CustomTextEditor: NSViewRepresentable { func cancelPendingBindingSync() { pendingBindingSync?.cancel() + pendingBindingSync = nil + } + + func syncBindingTextImmediately(_ text: String) { + syncBindingText(text, immediate: true) } func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) { @@ -2422,7 +2451,7 @@ struct CustomTextEditor: NSViewRepresentable { sanitized = AcceptingTextView.sanitizePlainText(currentText) } if sanitized != currentText { - textView.string = sanitized + replaceTextPreservingSelectionAndFocus(textView, with: sanitized) } let normalizedStyle = NSMutableParagraphStyle() normalizedStyle.lineHeightMultiple = max(0.9, parent.lineHeightMultiple) @@ -2455,7 +2484,7 @@ struct CustomTextEditor: NSViewRepresentable { let caretLocation = min(nsText.length, textView.selectedRange().location) pendingEditedRange = nsText.lineRange(for: NSRange(location: caretLocation, length: 0)) updateCaretStatusAndHighlight(triggerHighlight: false) - scheduleHighlightIfNeeded(currentText: parent.text) + scheduleHighlightIfNeeded(currentText: sanitized) } func textViewDidChangeSelection(_ notification: Notification) { @@ -2934,12 +2963,14 @@ struct CustomTextEditor: UIViewRepresentable { let textView = uiView.textView context.coordinator.parent = self if textView.text != text { - context.coordinator.cancelPendingBindingSync() - let priorSelection = textView.selectedRange - let priorOffset = textView.contentOffset - let wasFirstResponder = textView.isFirstResponder - textView.text = text - if wasFirstResponder { + let shouldPreferEditorBuffer = textView.isFirstResponder && !isTabLoadingContent + if shouldPreferEditorBuffer { + context.coordinator.syncBindingTextImmediately(textView.text) + } else { + context.coordinator.cancelPendingBindingSync() + let priorSelection = textView.selectedRange + let priorOffset = textView.contentOffset + textView.text = text let length = (textView.text as NSString).length let clampedLocation = min(priorSelection.location, length) let clampedLength = min(priorSelection.length, max(0, length - clampedLocation)) @@ -3003,6 +3034,7 @@ struct CustomTextEditor: UIViewRepresentable { private var lastTranslucencyEnabled: Bool? private var isApplyingHighlight = false private var highlightGeneration: Int = 0 + var hasPendingBindingSync: Bool { pendingBindingSync != nil } init(_ parent: CustomTextEditor) { self.parent = parent @@ -3027,12 +3059,14 @@ struct CustomTextEditor: UIViewRepresentable { return } pendingBindingSync?.cancel() + pendingBindingSync = nil if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length { parent.text = text return } let work = DispatchWorkItem { [weak self] in guard let self else { return } + self.pendingBindingSync = nil if self.textView?.text == text { self.parent.text = text } @@ -3043,6 +3077,11 @@ struct CustomTextEditor: UIViewRepresentable { func cancelPendingBindingSync() { pendingBindingSync?.cancel() + pendingBindingSync = nil + } + + func syncBindingTextImmediately(_ text: String) { + syncBindingText(text, immediate: true) } @objc private func updateKeyboardAccessoryVisibility(_ notification: Notification) { diff --git a/Neon Vision Editor/UI/MarkdownPreviewWebView.swift b/Neon Vision Editor/UI/MarkdownPreviewWebView.swift new file mode 100644 index 0000000..f2845bd --- /dev/null +++ b/Neon Vision Editor/UI/MarkdownPreviewWebView.swift @@ -0,0 +1,33 @@ +#if os(macOS) +import SwiftUI +import WebKit + +struct MarkdownPreviewWebView: NSViewRepresentable { + let html: String + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.setValue(false, forKey: "drawsBackground") + webView.allowsBackForwardNavigationGestures = false + webView.loadHTMLString(html, baseURL: nil) + context.coordinator.lastHTML = html + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + guard context.coordinator.lastHTML != html else { return } + webView.loadHTMLString(html, baseURL: nil) + context.coordinator.lastHTML = html + } + + final class Coordinator { + var lastHTML: String = "" + } +} +#endif diff --git a/Neon Vision Editor/UI/PanelsAndHelpers.swift b/Neon Vision Editor/UI/PanelsAndHelpers.swift index 75f6d34..5592169 100644 --- a/Neon Vision Editor/UI/PanelsAndHelpers.swift +++ b/Neon Vision Editor/UI/PanelsAndHelpers.swift @@ -242,12 +242,11 @@ struct WelcomeTourView: View { private let pages: [TourPage] = [ TourPage( title: "What’s New in This Release", - subtitle: "Major changes since v0.4.28:", + subtitle: "Major changes since v0.4.29:", bullets: [ - "Added explicit English (`en`) and German (`de`) support strings for the Support/IAP settings surface to keep release copy consistent across locales.", - "Added support-price freshness state with a visible “Last updated” timestamp in Support settings after successful App Store product refreshes.", - "Improved updater version normalization so release tags with suffix metadata (for example `+build`, `(build 123)`, or prefixed release labels) are compared using the semantic core version.", - "Improved Support settings refresh UX with a loading spinner on the “Retry App Store” action and clearer status messaging when price data is temporarily unavailable." + "TODO", + "TODO", + "TODO" ], iconName: "sparkles.rectangle.stack", colors: [Color(red: 0.40, green: 0.28, blue: 0.90), Color(red: 0.96, green: 0.46, blue: 0.55)], diff --git a/Neon Vision Editor/de.lproj/Localizable.strings b/Neon Vision Editor/de.lproj/Localizable.strings index a67ecaa..dbea620 100644 --- a/Neon Vision Editor/de.lproj/Localizable.strings +++ b/Neon Vision Editor/de.lproj/Localizable.strings @@ -34,3 +34,126 @@ "Purchase did not complete." = "Der Kauf wurde nicht abgeschlossen."; "Purchase failed: %@" = "Kauf fehlgeschlagen: %@"; "Transaction verification failed." = "Transaktionsprüfung fehlgeschlagen."; + +"General" = "Allgemein"; +"Editor" = "Editor"; +"Templates" = "Vorlagen"; +"Themes" = "Themes"; +"More" = "Mehr"; +"AI" = "KI"; +"Updates" = "Updates"; +"AI Model" = "KI-Modell"; +"Appearance" = "Darstellung"; +"Display" = "Anzeige"; +"Font" = "Schrift"; +"Editor Font" = "Editor-Schrift"; +"Font Size" = "Schriftgröße"; +"Line Height" = "Zeilenhöhe"; +"Language" = "Sprache"; +"Default New File Language" = "Standardsprache für neue Dateien"; +"Startup" = "Start"; +"Open in Tabs" = "In Tabs öffnen"; +"Open with Blank Document" = "Mit leerem Dokument öffnen"; +"Reopen Last Session" = "Letzte Sitzung wiederherstellen"; +"Translucency" = "Transluzenz"; +"Translucency Mode" = "Transluzenzmodus"; +"Translucent Window" = "Transparentes Fenster"; +"Translucent Window Background" = "Transparenter Fensterhintergrund"; +"Follow System" = "System folgen"; +"Always" = "Immer"; +"Never" = "Nie"; +"System" = "System"; +"Light" = "Hell"; +"Dark" = "Dunkel"; +"Completion" = "Vervollständigung"; +"Enable Completion" = "Vervollständigung aktivieren"; +"Include Syntax Keywords" = "Syntax-Schlüsselwörter einbeziehen"; +"Include Words in Document" = "Wörter im Dokument einbeziehen"; +"For lower latency on large files, keep only one completion source enabled." = "Für geringere Latenz bei großen Dateien sollte nur eine Vervollständigungsquelle aktiv sein."; +"The selected AI model is used for AI-assisted code completion." = "Das ausgewählte KI-Modell wird für KI-gestützte Code-Vervollständigung verwendet."; +"Apple Intelligence" = "Apple Intelligence"; +"Grok" = "Grok"; +"OpenAI" = "OpenAI"; +"Gemini" = "Gemini"; +"Anthropic" = "Anthropic"; +"AI Provider API Keys" = "API-Schlüssel der KI-Anbieter"; +"Data Disclosure" = "Datentransparenz"; +"AI Data Disclosure" = "KI-Datentransparenz"; +"Close" = "Schließen"; +"Done" = "Fertig"; +"Cancel" = "Abbrechen"; +"Save" = "Speichern"; +"Don't Save" = "Nicht speichern"; +"OK" = "OK"; +"Software Update" = "Software-Update"; +"Checks GitHub releases for Neon Vision Editor updates." = "Prüft GitHub-Releases auf Neon Vision Editor-Updates."; +"Show Installer Log" = "Installer-Log anzeigen"; +"Checking for updates…" = "Suche nach Updates…"; +"You’re up to date." = "Du bist auf dem neuesten Stand."; +"Update check failed" = "Update-Prüfung fehlgeschlagen"; +"Unknown error" = "Unbekannter Fehler"; +"%@ is available" = "%@ ist verfügbar"; +"Current version: %@" = "Aktuelle Version: %@"; +"Current version: %@ • New version: %@" = "Aktuelle Version: %@ • Neue Version: %@"; +"Published: %@" = "Veröffentlicht: %@"; +"Installing update…" = "Update wird installiert…"; +"%d%%" = "%d%%"; +"Updates are delivered from GitHub release assets, not App Store updates." = "Updates werden über GitHub-Release-Assets bereitgestellt, nicht über App-Store-Updates."; +"Later" = "Später"; +"Install and Close App" = "Installieren und App schließen"; +"Restart and Install" = "Neustarten und installieren"; +"View Releases" = "Releases ansehen"; +"Skip This Version" = "Diese Version überspringen"; +"Remind Me Tomorrow" = "Morgen erinnern"; +"Install Update" = "Update installieren"; +"Try Again" = "Erneut versuchen"; +"Check Again" = "Erneut prüfen"; +"Check Now" = "Jetzt prüfen"; +"GitHub Release Updates" = "GitHub-Release-Updates"; +"Automatically check for updates" = "Automatisch nach Updates suchen"; +"Check Interval" = "Prüfintervall"; +"Automatically install updates when available" = "Updates automatisch installieren, wenn verfügbar"; +"Last checked: %@" = "Zuletzt geprüft: %@"; +"Last check result: %@" = "Letztes Prüfergebnis: %@"; +"Auto-check pause active until %@ (%lld consecutive failures)." = "Automatische Prüfung pausiert bis %@ (%lld aufeinanderfolgende Fehler)."; +"Uses GitHub release assets only. App Store Connect releases are not used by this updater." = "Dieser Updater nutzt ausschließlich GitHub-Release-Assets. App-Store-Connect-Releases werden nicht verwendet."; +"Find & Replace" = "Suchen & Ersetzen"; +"Find" = "Suchen"; +"Replace" = "Ersetzen"; +"Replace All" = "Alle ersetzen"; +"Case Sensitive" = "Groß-/Kleinschreibung beachten"; +"Use Regex" = "Regex verwenden"; +"Quick Open" = "Schnell öffnen"; +"No folder selected" = "Kein Ordner ausgewählt"; +"Folder is empty" = "Ordner ist leer"; +"Project Structure" = "Projektstruktur"; +"Show Line Numbers" = "Zeilennummern anzeigen"; +"Toolbar buttons" = "Toolbar-Schaltflächen"; +"scroll for viewing all toolbar options." = "scrollen, um alle Toolbar-Optionen zu sehen."; +"This file has unsaved changes." = "Diese Datei enthält ungespeicherte Änderungen."; +"This will remove all text in the current editor." = "Dadurch wird der gesamte Text im aktuellen Editor entfernt."; +"Untitled 1" = "Unbenannt 1"; +"Large File Mode" = "Großdatei-Modus"; +"Theme Colors" = "Theme-Farben"; +"Base" = "Basis"; +"Syntax" = "Syntax"; +"Preview" = "Vorschau"; +"Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tipp: Aktiviere nur einen Startmodus, damit das Startverhalten der App vorhersehbar bleibt."; +"When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "Wenn Zeilenumbruch aktiv ist, werden Scope-Guides/Scope-Bereich deaktiviert, um Layoutkonflikte zu vermeiden."; +"Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "Scope-Guides sind für Nicht-Swift-Sprachen gedacht. Für Swift wird die Hervorhebung passender Tokens bevorzugt."; +"Invisible character markers are disabled to avoid whitespace glyph artifacts." = "Markierungen für unsichtbare Zeichen sind deaktiviert, um Whitespace-Artefakte zu vermeiden."; +"Indentation" = "Einrückung"; +"Spaces" = "Leerzeichen"; +"Tabs" = "Tabs"; +"Indent Width: %lld" = "Einrückungsbreite: %lld"; +"Choose a language for code completion" = "Wähle eine Sprache für die Code-Vervollständigung"; +"You can change this later from the Language picker." = "Du kannst dies später im Sprachwähler ändern."; +"Purchase is available in App Store/TestFlight builds." = "Kauf ist in App-Store-/TestFlight-Builds verfügbar."; +"Send Support Tip — %@" = "Support-Tipp senden — %@"; +"Send Tip %@" = "Tipp senden %@"; +"The application does not collect analytics data, usage telemetry, advertising identifiers, device fingerprints, or background behavioral metrics. No automatic data transmission to developer-controlled servers occurs." = "Die Anwendung erfasst keine Analysedaten, Nutzungs-Telemetrie, Werbe-IDs, Geräte-Fingerprints oder Verhaltensmetriken im Hintergrund. Es erfolgt keine automatische Datenübertragung an vom Entwickler kontrollierte Server."; +"AI-assisted code completion is an optional feature. External network communication only occurs when a user explicitly enables AI completion and selects an external AI provider within the application settings." = "KI-gestützte Code-Vervollständigung ist eine optionale Funktion. Externe Netzwerkkommunikation erfolgt nur, wenn ein Benutzer KI-Vervollständigung explizit aktiviert und in den Einstellungen einen externen KI-Anbieter auswählt."; +"When AI completion is triggered, the application transmits only the minimal contextual text necessary to generate a completion suggestion. This typically includes the code immediately surrounding the cursor position or the active selection." = "Wenn KI-Vervollständigung ausgelöst wird, übermittelt die Anwendung nur den minimal notwendigen Kontexttext zur Erzeugung eines Vorschlags. Dazu gehört typischerweise der Code direkt um die Cursorposition oder die aktive Auswahl."; +"The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers." = "Die Anwendung überträgt nicht automatisch ganze Projektordner, nicht zusammenhängende Dateien, vollständige Dateisysteminhalte, Kontaktdaten, Standortdaten oder gerätespezifische Kennungen."; +"Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request." = "Authentifizierungsdaten (API-Schlüssel) für externe KI-Anbieter werden sicher im System-Schlüsselbund gespeichert und nur an den vom Benutzer gewählten Anbieter zur Bearbeitung der KI-Anfrage übertragen."; +"All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests." = "Sämtliche externe Kommunikation erfolgt über verschlüsselte HTTPS-Verbindungen. Wenn KI-Vervollständigung deaktiviert ist, führt die Anwendung keine externen KI-bezogenen Netzwerkanfragen aus."; diff --git a/Neon Vision Editor/en.lproj/Localizable.strings b/Neon Vision Editor/en.lproj/Localizable.strings index b349eba..8ec7246 100644 --- a/Neon Vision Editor/en.lproj/Localizable.strings +++ b/Neon Vision Editor/en.lproj/Localizable.strings @@ -34,3 +34,126 @@ "Purchase did not complete." = "Purchase did not complete."; "Purchase failed: %@" = "Purchase failed: %@"; "Transaction verification failed." = "Transaction verification failed."; + +"General" = "General"; +"Editor" = "Editor"; +"Templates" = "Templates"; +"Themes" = "Themes"; +"More" = "More"; +"AI" = "AI"; +"Updates" = "Updates"; +"AI Model" = "AI Model"; +"Appearance" = "Appearance"; +"Display" = "Display"; +"Font" = "Font"; +"Editor Font" = "Editor Font"; +"Font Size" = "Font Size"; +"Line Height" = "Line Height"; +"Language" = "Language"; +"Default New File Language" = "Default New File Language"; +"Startup" = "Startup"; +"Open in Tabs" = "Open in Tabs"; +"Open with Blank Document" = "Open with Blank Document"; +"Reopen Last Session" = "Reopen Last Session"; +"Translucency" = "Translucency"; +"Translucency Mode" = "Translucency Mode"; +"Translucent Window" = "Translucent Window"; +"Translucent Window Background" = "Translucent Window Background"; +"Follow System" = "Follow System"; +"Always" = "Always"; +"Never" = "Never"; +"System" = "System"; +"Light" = "Light"; +"Dark" = "Dark"; +"Completion" = "Completion"; +"Enable Completion" = "Enable Completion"; +"Include Syntax Keywords" = "Include Syntax Keywords"; +"Include Words in Document" = "Include Words in Document"; +"For lower latency on large files, keep only one completion source enabled." = "For lower latency on large files, keep only one completion source enabled."; +"The selected AI model is used for AI-assisted code completion." = "The selected AI model is used for AI-assisted code completion."; +"Apple Intelligence" = "Apple Intelligence"; +"Grok" = "Grok"; +"OpenAI" = "OpenAI"; +"Gemini" = "Gemini"; +"Anthropic" = "Anthropic"; +"AI Provider API Keys" = "AI Provider API Keys"; +"Data Disclosure" = "Data Disclosure"; +"AI Data Disclosure" = "AI Data Disclosure"; +"Close" = "Close"; +"Done" = "Done"; +"Cancel" = "Cancel"; +"Save" = "Save"; +"Don't Save" = "Don't Save"; +"OK" = "OK"; +"Software Update" = "Software Update"; +"Checks GitHub releases for Neon Vision Editor updates." = "Checks GitHub releases for Neon Vision Editor updates."; +"Show Installer Log" = "Show Installer Log"; +"Checking for updates…" = "Checking for updates…"; +"You’re up to date." = "You’re up to date."; +"Update check failed" = "Update check failed"; +"Unknown error" = "Unknown error"; +"%@ is available" = "%@ is available"; +"Current version: %@" = "Current version: %@"; +"Current version: %@ • New version: %@" = "Current version: %@ • New version: %@"; +"Published: %@" = "Published: %@"; +"Installing update…" = "Installing update…"; +"%d%%" = "%d%%"; +"Updates are delivered from GitHub release assets, not App Store updates." = "Updates are delivered from GitHub release assets, not App Store updates."; +"Later" = "Later"; +"Install and Close App" = "Install and Close App"; +"Restart and Install" = "Restart and Install"; +"View Releases" = "View Releases"; +"Skip This Version" = "Skip This Version"; +"Remind Me Tomorrow" = "Remind Me Tomorrow"; +"Install Update" = "Install Update"; +"Try Again" = "Try Again"; +"Check Again" = "Check Again"; +"Check Now" = "Check Now"; +"GitHub Release Updates" = "GitHub Release Updates"; +"Automatically check for updates" = "Automatically check for updates"; +"Check Interval" = "Check Interval"; +"Automatically install updates when available" = "Automatically install updates when available"; +"Last checked: %@" = "Last checked: %@"; +"Last check result: %@" = "Last check result: %@"; +"Auto-check pause active until %@ (%lld consecutive failures)." = "Auto-check pause active until %@ (%lld consecutive failures)."; +"Uses GitHub release assets only. App Store Connect releases are not used by this updater." = "Uses GitHub release assets only. App Store Connect releases are not used by this updater."; +"Find & Replace" = "Find & Replace"; +"Find" = "Find"; +"Replace" = "Replace"; +"Replace All" = "Replace All"; +"Case Sensitive" = "Case Sensitive"; +"Use Regex" = "Use Regex"; +"Quick Open" = "Quick Open"; +"No folder selected" = "No folder selected"; +"Folder is empty" = "Folder is empty"; +"Project Structure" = "Project Structure"; +"Show Line Numbers" = "Show Line Numbers"; +"Toolbar buttons" = "Toolbar buttons"; +"scroll for viewing all toolbar options." = "scroll for viewing all toolbar options."; +"This file has unsaved changes." = "This file has unsaved changes."; +"This will remove all text in the current editor." = "This will remove all text in the current editor."; +"Untitled 1" = "Untitled 1"; +"Large File Mode" = "Large File Mode"; +"Theme Colors" = "Theme Colors"; +"Base" = "Base"; +"Syntax" = "Syntax"; +"Preview" = "Preview"; +"Tip: Enable only one startup mode to keep app launch behavior predictable." = "Tip: Enable only one startup mode to keep app launch behavior predictable."; +"When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts." = "When Line Wrap is enabled, scope guides/scoped region are turned off to avoid layout conflicts."; +"Scope guides are intended for non-Swift languages. Swift favors matching-token highlight." = "Scope guides are intended for non-Swift languages. Swift favors matching-token highlight."; +"Invisible character markers are disabled to avoid whitespace glyph artifacts." = "Invisible character markers are disabled to avoid whitespace glyph artifacts."; +"Indentation" = "Indentation"; +"Spaces" = "Spaces"; +"Tabs" = "Tabs"; +"Indent Width: %lld" = "Indent Width: %lld"; +"Choose a language for code completion" = "Choose a language for code completion"; +"You can change this later from the Language picker." = "You can change this later from the Language picker."; +"Purchase is available in App Store/TestFlight builds." = "Purchase is available in App Store/TestFlight builds."; +"Send Support Tip — %@" = "Send Support Tip — %@"; +"Send Tip %@" = "Send Tip %@"; +"The application does not collect analytics data, usage telemetry, advertising identifiers, device fingerprints, or background behavioral metrics. No automatic data transmission to developer-controlled servers occurs." = "The application does not collect analytics data, usage telemetry, advertising identifiers, device fingerprints, or background behavioral metrics. No automatic data transmission to developer-controlled servers occurs."; +"AI-assisted code completion is an optional feature. External network communication only occurs when a user explicitly enables AI completion and selects an external AI provider within the application settings." = "AI-assisted code completion is an optional feature. External network communication only occurs when a user explicitly enables AI completion and selects an external AI provider within the application settings."; +"When AI completion is triggered, the application transmits only the minimal contextual text necessary to generate a completion suggestion. This typically includes the code immediately surrounding the cursor position or the active selection." = "When AI completion is triggered, the application transmits only the minimal contextual text necessary to generate a completion suggestion. This typically includes the code immediately surrounding the cursor position or the active selection."; +"The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers." = "The application does not automatically transmit full project folders, unrelated files, entire file system contents, contact data, location data, or device-specific identifiers."; +"Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request." = "Authentication credentials (API keys) for external AI providers are stored securely in the system keychain and are transmitted only to the user-selected provider for the purpose of completing the AI request."; +"All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests." = "All external communication is performed over encrypted HTTPS connections. If AI completion is disabled, the application performs no external AI-related network requests."; diff --git a/README.md b/README.md index 76eb987..f95cc02 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ > Status: **active release** -> Latest release: **v0.4.29** +> Latest release: **v0.4.30** > Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia** > Apple Silicon: tested / Intel: not tested @@ -30,7 +30,7 @@ Prebuilt binaries are available on [GitHub Releases](https://github.com/h3pdesign/Neon-Vision-Editor/releases). -- Latest release: **v0.4.29** +- Latest release: **v0.4.30** - Apple AppStore [On the AppStore](https://apps.apple.com/de/app/neon-vision-editor/id6758950965) - TestFlight beta: [Join here](https://testflight.apple.com/join/YWB2fGAP) - Architecture: Apple Silicon (Intel not tested) @@ -128,6 +128,12 @@ If macOS blocks first launch: ## Changelog +### v0.4.30 (summary) + +- TODO +- TODO +- TODO + ### v0.4.29 (summary) - Added explicit English (`en`) and German (`de`) support strings for the Support/IAP settings surface to keep release copy consistent across locales. @@ -144,13 +150,6 @@ If macOS blocks first launch: - Improved macOS Settings UX with smoother tab-to-tab size transitions and tighter dynamic window sizing. - Improved iOS/iPadOS toolbar language picker sizing so compact labels remain single-line and avoid clipping. -### v0.4.27 (summary) - -- Added compact iOS/iPadOS toolbar language labels and tightened picker widths to free toolbar space on smaller screens. -- Improved iPad toolbar density/alignment so more actions are visible before overflow and controls start further left. -- Improved macOS translucent chrome consistency between toolbar, tab strip, and project-sidebar header surfaces. -- Fixed macOS project-sidebar top/header transparency bleed when unified translucent toolbar backgrounds are enabled. - Full release history: [`CHANGELOG.md`](CHANGELOG.md) ## Known Limitations @@ -170,12 +169,12 @@ Full release history: [`CHANGELOG.md`](CHANGELOG.md) ## Release Integrity -- Tag: `v0.4.29` +- Tag: `v0.4.30` - Tagged commit: `1c31306` - Verify local tag target: ```bash -git rev-parse --verify v0.4.29 +git rev-parse --verify v0.4.30 ``` - Verify downloaded artifact checksum locally: