diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a1782f2182c4..7c15a9fecfaa 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -53138,6 +53138,125 @@ } } }, + "settings.app.newSurfacePlacement": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Surface Placement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規サーフェスの配置" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新 Surface 位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增 Surface 位置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 화면 위치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Platzierung neuer Oberflächen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ubicación de nueva superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Emplacement des nouvelles surfaces" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Posizionamento nuova superficie" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Placering af ny overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Umieszczenie nowej powierzchni" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расположение новой поверхности" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pozicija nove površine" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موضع السطح الجديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Plassering av ny flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Posição da Nova Superfície" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ตำแหน่งพื้นผิวใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Yüzey Konumu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розташування нової поверхні" + } + } + } + }, "settings.app.workspaceInheritWorkingDirectory": { "extractionState": "manual", "localizations": { @@ -72916,6 +73035,125 @@ } } }, + "settings.search.alias.setting.app.new-surface-placement": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current 新規サーフェス 新しいタブ 挿入位置 並び順 先頭 末尾 現在の後" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current 应用 通用 偏好设置 Surface 标签页 插入 位置 顺序 开头 末尾 当前之后 新 Surface 位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current 應用程式 一般 偏好設定 Surface 分頁 插入 位置 順序 開頭 末尾 目前之後 新增 Surface 位置" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current 앱 일반 환경설정 화면 탭 삽입 위치 순서 시작 끝 현재 다음 새 화면 위치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current app allgemein einstellungen Oberfläche Tab einfügen Position Reihenfolge Anfang Ende nach aktuellem Platzierung neuer Oberflächen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current aplicación general preferencias superficie pestaña insertar posición orden inicio final después del actual ubicación de nueva superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current application général préférences surface onglet insertion position ordre début fin après l'actuel emplacement des nouvelles surfaces" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current app generale preferenze superficie scheda inserisci posizione ordine inizio fine dopo la corrente posizionamento nuova superficie" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current app generelt indstillinger overflade fane indsæt placering rækkefølge start sidst efter nuværende placering af ny overflade" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current aplikacja ogólne preferencje powierzchnia karta wstaw pozycja kolejność początek koniec po bieżącej umieszczenie nowej powierzchni" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current приложение общие настройки поверхность вкладка вставить позиция порядок начало конец после текущего расположение новой поверхности" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current aplikacija opće postavke površina kartica umetni pozicija redoslijed početak kraj poslije trenutnog pozicija nove površine" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current تطبيق عام تفضيلات سطح تبويب إدراج موضع ترتيب بداية نهاية بعد الحالي موضع السطح الجديد" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current app generelt innstillinger flate fane sett inn plassering rekkefølge start slutt etter gjeldende plassering av ny flate" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current app geral preferências superfície aba inserir posição ordem início final após a atual posição da nova superfície" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current แอป ทั่วไป ค่ากำหนด พื้นผิว แท็บ แทรก ตำแหน่ง ลำดับ เริ่มต้น ท้ายสุด หลังรายการปัจจุบัน ตำแหน่งพื้นผิวใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current uygulama genel tercihler yüzey sekme ekle konum sıra başlangıç son geçerli sonrasına yeni yüzey konumu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "app.newSurfacePlacement surface tab insert position order start end after current програма загальні параметри поверхня вкладка вставити позиція порядок початок кінець після поточної розташування нової поверхні" + } + } + } + }, "settings.search.alias.setting.app.workspace-inherit-working-directory": { "extractionState": "manual", "localizations": { @@ -100963,6 +101201,720 @@ } } }, + "surface.placement.afterCurrent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After current" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在の後" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前之后" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目前之後" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 다음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach aktuellem" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Después del actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Après l'actuel" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dopo la corrente" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Efter nuværende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Po bieżącej" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После текущего" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Poslije trenutnog" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعد الحالي" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Etter gjeldende" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Após a atual" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หลังรายการปัจจุบัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçerli sonrasına" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Після поточної" + } + } + } + }, + "surface.placement.afterCurrent.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insert new surfaces directly after the active surface." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブなサーフェスの直後に新しいサーフェスを挿入します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在活动 Surface 之后插入新 Surface。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新 Surface 插入目前使用中 Surface 的後方。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성 화면 바로 다음에 새 화면을 삽입합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Oberflächen direkt nach der aktiven Oberfläche einfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Insertar nuevas superficies justo después de la superficie activa." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Insérer les nouvelles surfaces juste après la surface active." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci le nuove superfici subito dopo quella attiva." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indsæt nye overflader direkte efter den aktive overflade." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstaw nowe powierzchnie bezpośrednio po aktywnej." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вставлять новые поверхности сразу после активной." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umetnite nove površine odmah nakon aktivne površine." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدراج الأسطح الجديدة مباشرة بعد السطح النشط." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett inn nye flater rett etter den aktive flaten." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inserir novas superfícies diretamente após a superfície ativa." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แทรกพื้นผิวใหม่หลังพื้นผิวที่ใช้งานอยู่โดยตรง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni yüzeyleri etkin yüzeyin hemen sonrasına ekle." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вставляти нові поверхні одразу після активної." + } + } + } + }, + "surface.placement.end": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "End" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "末尾" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "末尾" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "底部" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끝" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ende" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Final" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fin" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fine" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sidst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na końcu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В конец" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kraj" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "النهاية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slutt" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Final" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ท้ายสุด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sona" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "В кінці" + } + } + } + }, + "surface.placement.end.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Append new surfaces to the end of the pane's tab strip." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいサーフェスをペインのタブ列の末尾に追加します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将新 Surface 添加到窗格标签栏末尾。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新 Surface 附加到窗格分頁列末尾。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 화면을 창의 탭 표시줄 끝에 추가합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Oberflächen am Ende der Tab-Leiste des Bereichs anfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar nuevas superficies al final de la barra de pestañas del panel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter les nouvelles surfaces à la fin de la barre d'onglets du panneau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggiungi le nuove superfici alla fine della barra delle schede del riquadro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføj nye overflader sidst på rudens fanelinje." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dodaj nowe powierzchnie na końcu paska kart panelu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавлять новые поверхности в конец панели вкладок области." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodajte nove površine na kraj trake kartica panela." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلحاق الأسطح الجديدة بنهاية شريط تبويبات اللوحة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg til nye flater på slutten av faneraden i ruten." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Adicionar novas superfícies ao final da barra de abas do painel." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่มพื้นผิวใหม่ที่ท้ายแถบแท็บของบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni yüzeyleri bölmenin sekme çubuğunun sonuna ekle." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додавати нові поверхні в кінець панелі вкладок області." + } + } + } + }, + "surface.placement.top": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先頭" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开头" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開頭" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anfang" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Début" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inizio" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Początek" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В начало" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Početak" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البداية" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Início" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เริ่มต้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlangıç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "На початку" + } + } + } + }, + "surface.placement.top.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Insert new surfaces at the start of the pane's tab strip." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいサーフェスをペインのタブ列の先頭に挿入します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在窗格标签栏开头插入新 Surface。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將新 Surface 插入窗格分頁列開頭。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 화면을 창의 탭 표시줄 시작 부분에 삽입합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neue Oberflächen am Anfang der Tab-Leiste des Bereichs einfügen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Insertar nuevas superficies al inicio de la barra de pestañas del panel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Insérer les nouvelles surfaces au début de la barre d'onglets du panneau." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inserisci le nuove superfici all'inizio della barra delle schede del riquadro." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indsæt nye overflader først på rudens fanelinje." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstaw nowe powierzchnie na początku paska kart panelu." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вставлять новые поверхности в начало панели вкладок области." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Umetnite nove površine na početak trake kartica panela." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدراج الأسطح الجديدة في بداية شريط تبويبات اللوحة." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett inn nye flater ved starten av faneraden i ruten." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Inserir novas superfícies no início da barra de abas do painel." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แทรกพื้นผิวใหม่ที่จุดเริ่มต้นของแถบแท็บของบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni yüzeyleri bölmenin sekme çubuğunun başına ekle." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вставляти нові поверхні на початок панелі вкладок області." + } + } + } + }, "workspace.placement.afterCurrent": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index e5b78a44a84a..22578c853cc4 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -47,6 +47,7 @@ extension CmuxSettingsFileStore { "app.appIcon", "app.menuBarOnly", "app.newWorkspacePlacement", + "app.newSurfacePlacement", "app.workspaceInheritWorkingDirectory", "app.minimalMode", "app.keepWorkspaceOpenWhenClosingLastSurface", diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 32e82d9834cd..be21fc975e9c 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -61,6 +61,7 @@ extension CmuxSettingsFileStore { "appIcon": AppIconSettings.defaultMode.rawValue, "menuBarOnly": MenuBarOnlySettings.defaultMenuBarOnly, "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, + "newSurfacePlacement": SurfacePlacementSettings.defaultPlacement.rawValue, "workspaceInheritWorkingDirectory": WorkspaceWorkingDirectoryInheritanceSettings.defaultValue, "minimalMode": false, "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 12039da44a6d..f70cc25f46e4 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -374,6 +374,13 @@ final class CmuxSettingsFileStore { } snapshot.managedUserDefaults[WorkspacePlacementSettings.placementKey] = .string(placement.rawValue) } + if let raw = jsonString(section["newSurfacePlacement"]) { + guard let placement = NewSurfacePlacement(rawValue: raw) else { + logInvalid("app.newSurfacePlacement", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SurfacePlacementSettings.placementKey] = .string(placement.rawValue) + } if let value = jsonBool(section["workspaceInheritWorkingDirectory"]) { snapshot.managedUserDefaults[WorkspaceWorkingDirectoryInheritanceSettings.key] = .bool(value) } else if section.keys.contains("workspaceInheritWorkingDirectory") { diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index a32da8154345..81671e43662c 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -293,6 +293,7 @@ enum SettingsSearchIndex { setting(.app, "appearance", String(localized: "settings.app.appearance", defaultValue: "Appearance"), "theme light dark system"), setting(.app, "app-icon", String(localized: "settings.app.appIcon", defaultValue: "App Icon"), "dock icon alternate"), setting(.app, "new-workspace-placement", String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), "workspace order position"), + setting(.app, "new-surface-placement", String(localized: "settings.app.newSurfacePlacement", defaultValue: "New Surface Placement"), "surface tab order position pane"), setting(.app, "workspace-inherit-working-directory", String(localized: "settings.app.workspaceInheritWorkingDirectory", defaultValue: "Inherit Workspace Working Directory"), "workspace cwd directory current ghostty working-directory"), setting(.app, "minimal-mode", String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), "presentation compact chrome"), setting(.app, "keep-workspace-open", String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), "close last surface shortcut"), @@ -385,6 +386,7 @@ enum SettingsSearchIndex { "app.appearance": settingID(for: .app, idSuffix: "appearance"), "app.appIcon": settingID(for: .app, idSuffix: "app-icon"), "app.newWorkspacePlacement": settingID(for: .app, idSuffix: "new-workspace-placement"), + "app.newSurfacePlacement": settingID(for: .app, idSuffix: "new-surface-placement"), "app.workspaceInheritWorkingDirectory": settingID(for: .app, idSuffix: "workspace-inherit-working-directory"), "app.minimalMode": settingID(for: .app, idSuffix: "minimal-mode"), "app.keepWorkspaceOpenWhenClosingLastSurface": settingID(for: .app, idSuffix: "keep-workspace-open"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index f8883ec4c1c8..ba873661576f 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -44,6 +44,7 @@ enum SettingsSearchAliasIndex { "app:appearance": localized("settings.search.alias.setting.app.appearance", defaultValue: "app.appearance theme color scheme light mode dark mode system mode"), "app:app-icon": localized("settings.search.alias.setting.app.app-icon", defaultValue: "app.appIcon dock icon application icon app switcher alternate icon"), "app:new-workspace-placement": localized("settings.search.alias.setting.app.new-workspace-placement", defaultValue: "app.newWorkspacePlacement new tab insert position order top bottom end"), + "app:new-surface-placement": localized("settings.search.alias.setting.app.new-surface-placement", defaultValue: "app.newSurfacePlacement surface tab insert position order start end after current"), "app:workspace-inherit-working-directory": localized("settings.search.alias.setting.app.workspace-inherit-working-directory", defaultValue: "app.workspaceInheritWorkingDirectory workspace cwd directory inherit current focused ghostty working-directory"), "app:minimal-mode": localized("settings.search.alias.setting.app.minimal-mode", defaultValue: "app.minimalMode minimal layout simple chrome compact titlebar controls"), "app:keep-workspace-open": localized("settings.search.alias.setting.app.keep-workspace-open", defaultValue: "app.keepWorkspaceOpenWhenClosingLastSurface close last pane surface keep tab workspace"), diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5f751491abd4..b3786748b9d1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -48,6 +48,45 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { } } +enum NewSurfacePlacement: String, CaseIterable, Identifiable { + case top + case afterCurrent + case end + + var id: String { rawValue } + + var displayName: String { + switch self { + case .top: + return String(localized: "surface.placement.top", defaultValue: "Start") + case .afterCurrent: + return String(localized: "surface.placement.afterCurrent", defaultValue: "After current") + case .end: + return String(localized: "surface.placement.end", defaultValue: "End") + } + } + + var description: String { + switch self { + case .top: + return String( + localized: "surface.placement.top.description", + defaultValue: "Insert new surfaces at the start of the pane's tab strip." + ) + case .afterCurrent: + return String( + localized: "surface.placement.afterCurrent.description", + defaultValue: "Insert new surfaces directly after the active surface." + ) + case .end: + return String( + localized: "surface.placement.end.description", + defaultValue: "Append new surfaces to the end of the pane's tab strip." + ) + } + } +} + enum WorkspaceAutoReorderSettings { static let key = "workspaceAutoReorderOnNotification" static let defaultValue = true @@ -280,6 +319,47 @@ enum WorkspacePlacementSettings { } } +enum SurfacePlacementSettings { + static let placementKey = "newSurfacePlacement" + static let defaultPlacement: NewSurfacePlacement = .afterCurrent + + static func current(defaults: UserDefaults = .standard) -> NewSurfacePlacement { + guard let raw = defaults.string(forKey: placementKey), + let placement = NewSurfacePlacement(rawValue: raw) else { + return defaultPlacement + } + return placement + } + + static func insertionIndex( + placement: NewSurfacePlacement, + selectedIndexBeforeCreation: Int?, + selectedWasPinned: Bool, + pinnedCount: Int, + totalCountAfterCreation: Int + ) -> Int { + let clampedTotalCount = max(0, totalCountAfterCreation) + let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount)) + let lastIndex = max(0, clampedTotalCount - 1) + + switch placement { + case .top: + return clampedPinnedCount + case .end: + return lastIndex + case .afterCurrent: + guard let selectedIndexBeforeCreation, clampedTotalCount > 0 else { + return lastIndex + } + if selectedWasPinned { + return clampedPinnedCount + } + let clampedSelectedIndex = max(0, min(selectedIndexBeforeCreation, lastIndex)) + return max(min(clampedSelectedIndex + 1, lastIndex), clampedPinnedCount) + } + } +} + enum WorkspaceWorkingDirectoryInheritanceSettings { static let key = "workspaceInheritWorkingDirectory" static let defaultValue = true diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c61e6d09a653..da1734aafa3b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5734,20 +5734,6 @@ class TerminalController { result = .ok(payload) } - @MainActor - func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int { - let tabs = workspace.bonsplitController.tabs(inPane: paneId) - guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } - let pinnedCount = tabs.reduce(into: 0) { count, tab in - if let panelId = workspace.panelIdFromSurfaceId(tab.id), - workspace.isPanelPinned(panelId) { - count += 1 - } - } - let rawTarget = min(anchorIndex + 1, tabs.count) - return max(rawTarget, pinnedCount) - } - @MainActor func closeTabs(_ tabIds: [TabID]) -> (closed: Int, skippedPinned: Int) { var closed = 0 @@ -5840,12 +5826,15 @@ class TerminalController { return } - let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: focus) else { + let targetIndex = workspace.insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newTerminalSurface( + inPane: paneId, + focus: focus, + targetIndex: targetIndex + ) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } - _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) finish([ "created_surface_id": newPanel.id.uuidString, "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id), @@ -5875,12 +5864,17 @@ class TerminalController { return } - let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: focus) else { + let targetIndex = workspace.insertionIndexToRight(of: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: focus, + preferredProfileID: workspace.browserPanel(for: surfaceId)?.profileID, + targetIndex: targetIndex + ) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } - _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) finish([ "created_surface_id": newPanel.id.uuidString, "created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id), @@ -8055,14 +8049,14 @@ class TerminalController { var sourcePlaceholder: UUID? var targetPlaceholder: UUID? if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { - sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id + sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false, placementOverride: .end)?.id if sourcePlaceholder == nil { result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) return } } if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { - targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false, placementOverride: .end)?.id if targetPlaceholder == nil { result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) return diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb695864f374..e00554fa1895 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -768,7 +768,7 @@ extension Workspace { .first if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false, placementOverride: .end)?.id } guard let anchorPanelId, @@ -952,7 +952,8 @@ extension Workspace { initialCommand: restoredTmuxStartupScript?.path, tmuxStartCommand: restoredTmuxStartCommand, initialInput: restoredStartupInput, - startupEnvironment: replayEnvironment + startupEnvironment: replayEnvironment, + placementOverride: .end ) else { return nil } @@ -989,7 +990,8 @@ extension Workspace { url: nil, focus: false, preferredProfileID: snapshot.browser?.profileID, - creationPolicy: .restoration + creationPolicy: .restoration, + placementOverride: .end ) else { return nil } @@ -1000,7 +1002,8 @@ extension Workspace { let markdownPanel = newMarkdownSurface( inPane: paneId, filePath: filePath, - focus: false + focus: false, + placementOverride: .end ) else { return nil } @@ -1011,7 +1014,8 @@ extension Workspace { let filePreviewPanel = newFilePreviewSurface( inPane: paneId, filePath: filePath, - focus: false + focus: false, + placementOverride: .end ) else { return nil } @@ -1167,7 +1171,7 @@ extension Workspace { .first if anchorPanelId == nil { - anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false, placementOverride: .end)?.id } guard let anchorPanelId, @@ -1235,7 +1239,8 @@ extension Workspace { inPane: paneId, focus: false, workingDirectory: resolvedCwd, - startupEnvironment: surface.env ?? [:] + startupEnvironment: surface.env ?? [:], + placementOverride: .end ) { _ = closePanel(panelId, force: true) if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } @@ -1256,7 +1261,8 @@ extension Workspace { inPane: paneId, url: url, focus: false, - creationPolicy: .restoration + creationPolicy: .restoration, + placementOverride: .end ) { _ = closePanel(panelId, force: true) if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } @@ -1278,7 +1284,8 @@ extension Workspace { inPane: paneId, focus: false, workingDirectory: resolvedCwd, - startupEnvironment: surface.env ?? [:] + startupEnvironment: surface.env ?? [:], + placementOverride: .end ) { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } @@ -1291,7 +1298,8 @@ extension Workspace { inPane: paneId, url: url, focus: false, - creationPolicy: .restoration + creationPolicy: .restoration, + placementOverride: .end ) { if let name = surface.name { setPanelCustomTitle(panelId: panel.id, title: name) } if surface.focus == true { focusPanelId = panel.id } @@ -8565,7 +8573,50 @@ final class Workspace: Identifiable, ObservableObject { } } - private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { + private struct SurfaceSelectionBeforeCreation { + let index: Int? + let wasPinned: Bool + } + + private func surfaceSelectionBeforeCreation(inPane paneId: PaneID) -> SurfaceSelectionBeforeCreation { + let tabs = bonsplitController.tabs(inPane: paneId) + guard let selectedTabId = bonsplitController.selectedTab(inPane: paneId)?.id, + let selectedIndex = tabs.firstIndex(where: { $0.id == selectedTabId }) else { + return SurfaceSelectionBeforeCreation(index: nil, wasPinned: false) + } + let selectedPanelId = panelIdFromSurfaceId(selectedTabId) + return SurfaceSelectionBeforeCreation( + index: selectedIndex, + wasPinned: selectedPanelId.map { pinnedPanelIds.contains($0) } ?? false + ) + } + + private func applyNewSurfacePlacement( + to newTabId: TabID, + inPane paneId: PaneID, + selectionBeforeCreation: SurfaceSelectionBeforeCreation, + placementOverride: NewSurfacePlacement? = nil + ) { + let placement = placementOverride ?? SurfacePlacementSettings.current() + let tabs = bonsplitController.tabs(inPane: paneId) + guard let currentIndex = tabs.firstIndex(where: { $0.id == newTabId }) else { return } + let pinnedCount = tabs.reduce(into: 0) { count, tab in + if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) { + count += 1 + } + } + let targetIndex = SurfacePlacementSettings.insertionIndex( + placement: placement, + selectedIndexBeforeCreation: selectionBeforeCreation.index, + selectedWasPinned: selectionBeforeCreation.wasPinned, + pinnedCount: pinnedCount, + totalCountAfterCreation: tabs.count + ) + guard currentIndex != targetIndex else { return } + _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } + + func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int { let tabs = bonsplitController.tabs(inPane: paneId) guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count } let pinnedCount = tabs.reduce(into: 0) { count, tab in @@ -10507,9 +10558,12 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: String? = nil, tmuxStartCommand: String? = nil, initialInput: String? = nil, - startupEnvironment: [String: String] = [:] + startupEnvironment: [String: String] = [:], + targetIndex: Int? = nil, + placementOverride: NewSurfacePlacement? = nil ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let selectionBeforeCreation = surfaceSelectionBeforeCreation(inPane: paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -10566,6 +10620,16 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = newPanel.id + if let targetIndex { + _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } else { + applyNewSurfacePlacement( + to: newTabId, + inPane: paneId, + selectionBeforeCreation: selectionBeforeCreation, + placementOverride: placementOverride + ) + } publishCmuxSurfaceCreated(newPanel.id, paneId: paneId, kind: "terminal", origin: "terminal_tab", focused: shouldFocusNewTab) // bonsplit's createTab may not reliably emit didSelectTab, and its internal selection @@ -10712,7 +10776,9 @@ final class Workspace: Identifiable, ObservableObject { insertAtEnd: Bool = false, preferredProfileID: UUID? = nil, bypassInsecureHTTPHostOnce: String? = nil, - creationPolicy: BrowserPanelCreationPolicy = .userInitiated + creationPolicy: BrowserPanelCreationPolicy = .userInitiated, + targetIndex: Int? = nil, + placementOverride: NewSurfacePlacement? = nil ) -> BrowserPanel? { let browserEnabled = BrowserAvailabilitySettings.isEnabled() guard browserEnabled || creationPolicy.permitsCreationWhenBrowserDisabled else { @@ -10723,6 +10789,7 @@ final class Workspace: Identifiable, ObservableObject { } let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let selectionBeforeCreation = surfaceSelectionBeforeCreation(inPane: paneId) let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -10761,11 +10828,15 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTabId] = browserPanel.id setPreferredBrowserProfileID(browserPanel.profileID) - - // Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement. - if insertAtEnd { - let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1) + if let targetIndex { _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } else { + applyNewSurfacePlacement( + to: newTabId, + inPane: paneId, + selectionBeforeCreation: selectionBeforeCreation, + placementOverride: insertAtEnd ? .end : placementOverride + ) } publishCmuxSurfaceCreated(browserPanel.id, paneId: paneId, kind: "browser", origin: "browser_tab", focused: shouldFocusNewTab) @@ -10890,9 +10961,11 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, filePath: String, focus: Bool? = nil, - targetIndex: Int? = nil + targetIndex: Int? = nil, + placementOverride: NewSurfacePlacement? = nil ) -> MarkdownPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let selectionBeforeCreation = surfaceSelectionBeforeCreation(inPane: paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -10917,6 +10990,13 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTabId] = markdownPanel.id if let targetIndex { _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } else { + applyNewSurfacePlacement( + to: newTabId, + inPane: paneId, + selectionBeforeCreation: selectionBeforeCreation, + placementOverride: placementOverride + ) } publishCmuxSurfaceCreated(markdownPanel.id, paneId: paneId, kind: "markdown", origin: "markdown_tab", focused: shouldFocusNewTab) if shouldFocusNewTab { @@ -11048,9 +11128,11 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, filePath: String, focus: Bool? = nil, - targetIndex: Int? = nil + targetIndex: Int? = nil, + placementOverride: NewSurfacePlacement? = nil ) -> FilePreviewPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let selectionBeforeCreation = surfaceSelectionBeforeCreation(inPane: paneId) let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView @@ -11075,6 +11157,13 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTabId] = filePreviewPanel.id if let targetIndex { _ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex) + } else { + applyNewSurfacePlacement( + to: newTabId, + inPane: paneId, + selectionBeforeCreation: selectionBeforeCreation, + placementOverride: placementOverride + ) } publishCmuxSurfaceCreated(filePreviewPanel.id, paneId: paneId, kind: "file_preview", origin: "file_preview_tab", focused: shouldFocusNewTab) if shouldFocusNewTab { @@ -13211,22 +13300,34 @@ final class Workspace: Identifiable, ObservableObject { .filter { $0 != anchorTabId } } - private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) { - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + private func contextMenuSurfaceTargetIndex(anchorTabId: TabID, inPane paneId: PaneID) -> Int? { + // Bonsplit's action names say "to right" because that was the original + // after-current behavior. User-created context-menu surfaces still honor + // app.newSurfacePlacement; only afterCurrent needs an anchor-relative index. + guard SurfacePlacementSettings.current() == .afterCurrent else { return nil } + return insertionIndexToRight(of: anchorTabId, inPane: paneId) } - private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { - let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID } - guard let newPanel = newBrowserSurface( + private func createTerminalFromContextMenu(anchorTabId: TabID, inPane paneId: PaneID) { + _ = newTerminalSurface( + inPane: paneId, + focus: true, + targetIndex: contextMenuSurfaceTargetIndex(anchorTabId: anchorTabId, inPane: paneId) + ) + } + + private func browserProfileID(forSurface surfaceId: TabID) -> UUID? { + panelIdFromSurfaceId(surfaceId).flatMap { browserPanel(for: $0)?.profileID } + } + + private func createBrowserFromContextMenu(anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) { + _ = newBrowserSurface( inPane: paneId, url: url, focus: true, - preferredProfileID: preferredProfileID - ) else { return } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex) + preferredProfileID: browserProfileID(forSurface: anchorTabId), + targetIndex: contextMenuSurfaceTargetIndex(anchorTabId: anchorTabId, inPane: paneId) + ) } @discardableResult @@ -13235,14 +13336,13 @@ final class Workspace: Identifiable, ObservableObject { let paneId = paneId(forPanelId: panelId), let browser = browserPanel(for: panelId) else { return nil } let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId) - guard let newPanel = newBrowserSurface( + return newBrowserSurface( inPane: paneId, url: browser.currentURLForTabDuplication, focus: focus, - preferredProfileID: browser.profileID - ) else { return nil } - _ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) - return newPanel + preferredProfileID: browser.profileID, + targetIndex: targetIndex + ) } private func promptRenamePanel(tabId: TabID) { @@ -14636,7 +14736,7 @@ extension Workspace: BonsplitDelegate { "fallback=createTerminalAndDropPlaceholders" ) #endif - _ = newTerminalSurface(inPane: originalPane, focus: false) + _ = newTerminalSurface(inPane: originalPane, focus: false, placementOverride: .end) for tab in controller.tabs(inPane: originalPane) { if panelIdFromSurfaceId(tab.id) == nil { bonsplitController.closeTab(tab.id) @@ -14863,9 +14963,9 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } _ = moveSurfaceToAdjacentPane(panelId: panelId, direction: .right) case .newTerminalToRight: - createTerminalToRight(of: tab.id, inPane: pane) + createTerminalFromContextMenu(anchorTabId: tab.id, inPane: pane) case .newBrowserToRight: - createBrowserToRight(of: tab.id, inPane: pane) + createBrowserFromContextMenu(anchorTabId: tab.id, inPane: pane) case .reload: guard let panelId = panelIdFromSurfaceId(tab.id), let browser = browserPanel(for: panelId) else { return } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index aacfe3883975..3130c979dcf0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5059,6 +5059,7 @@ struct SettingsView: View { @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + @AppStorage(SurfacePlacementSettings.placementKey) private var newSurfacePlacement = SurfacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceWorkingDirectoryInheritanceSettings.key) private var workspaceInheritWorkingDirectory = WorkspaceWorkingDirectoryInheritanceSettings.defaultValue @AppStorage(LastSurfaceCloseShortcutSettings.key) @@ -5130,6 +5131,10 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var selectedSurfacePlacement: NewSurfacePlacement { + NewSurfacePlacement(rawValue: newSurfacePlacement) ?? SurfacePlacementSettings.defaultPlacement + } + private var workspaceWorkingDirectoryInheritanceSubtitle: String { if workspaceInheritWorkingDirectory { return String( @@ -5835,6 +5840,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsPickerRow( + configurationReview: .json("app.newSurfacePlacement"), + String(localized: "settings.app.newSurfacePlacement", defaultValue: "New Surface Placement"), + subtitle: selectedSurfacePlacement.description, + controlWidth: pickerColumnWidth, + selection: $newSurfacePlacement + ) { + ForEach(NewSurfacePlacement.allCases) { placement in + Text(placement.displayName).tag(placement.rawValue) + } + } + + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("app.workspaceInheritWorkingDirectory"), String( @@ -7444,6 +7463,7 @@ struct SettingsView: View { commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + newSurfacePlacement = SurfacePlacementSettings.defaultPlacement.rawValue workspaceInheritWorkingDirectory = WorkspaceWorkingDirectoryInheritanceSettings.defaultValue workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue let defaults = UserDefaults.standard diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5b1d0c2af7c9..c18a1cde39fb 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1716,7 +1716,9 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { func testReloadConfigurationReloadsManagedAppSettingsFromSettingsFile() throws { let defaults = UserDefaults.standard let managedKey = WorkspacePlacementSettings.placementKey + let managedSurfaceKey = SurfacePlacementSettings.placementKey let previousValue = defaults.object(forKey: managedKey) + let previousSurfaceValue = defaults.object(forKey: managedSurfaceKey) let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) defer { if let previousValue { @@ -1725,6 +1727,12 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { defaults.removeObject(forKey: managedKey) } + if let previousSurfaceValue { + defaults.set(previousSurfaceValue, forKey: managedSurfaceKey) + } else { + defaults.removeObject(forKey: managedSurfaceKey) + } + if let previousBackups { defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) } else { @@ -1733,6 +1741,7 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { } defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: managedSurfaceKey) defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) let directoryURL = try makeTemporaryDirectory() @@ -1743,7 +1752,8 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { """ { "app": { - "newWorkspacePlacement": "top" + "newWorkspacePlacement": "top", + "newSurfacePlacement": "top" } } """, @@ -1757,12 +1767,14 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { ) XCTAssertEqual(WorkspacePlacementSettings.current(), .top) + XCTAssertEqual(SurfacePlacementSettings.current(), .top) try writeSettingsFile( """ { "app": { - "newWorkspacePlacement": "end" + "newWorkspacePlacement": "end", + "newSurfacePlacement": "end" } } """, @@ -1772,6 +1784,7 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { GhosttyApp.shared.reloadConfiguration(source: "test.reload_config_app_setting") XCTAssertEqual(WorkspacePlacementSettings.current(), .end) + XCTAssertEqual(SurfacePlacementSettings.current(), .end) } @MainActor @@ -2376,6 +2389,85 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +final class SurfacePlacementSettingsTests: XCTestCase { + func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() { + let suiteName = "SurfacePlacementSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(SurfacePlacementSettings.current(defaults: defaults), .afterCurrent) + } + + func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() { + let suiteName = "SurfacePlacementSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(NewSurfacePlacement.top.rawValue, forKey: SurfacePlacementSettings.placementKey) + XCTAssertEqual(SurfacePlacementSettings.current(defaults: defaults), .top) + + defaults.set("nope", forKey: SurfacePlacementSettings.placementKey) + XCTAssertEqual(SurfacePlacementSettings.current(defaults: defaults), .afterCurrent) + } + + func testInsertionIndexTopPreservesPinnedSurfaces() { + let index = SurfacePlacementSettings.insertionIndex( + placement: .top, + selectedIndexBeforeCreation: 4, + selectedWasPinned: false, + pinnedCount: 2, + totalCountAfterCreation: 7 + ) + XCTAssertEqual(index, 2) + } + + func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() { + let afterUnpinned = SurfacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndexBeforeCreation: 3, + selectedWasPinned: false, + pinnedCount: 2, + totalCountAfterCreation: 6 + ) + XCTAssertEqual(afterUnpinned, 4) + + let afterPinned = SurfacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndexBeforeCreation: 0, + selectedWasPinned: true, + pinnedCount: 2, + totalCountAfterCreation: 6 + ) + XCTAssertEqual(afterPinned, 2) + } + + func testInsertionIndexEndAndNoSelectionAppend() { + let endIndex = SurfacePlacementSettings.insertionIndex( + placement: .end, + selectedIndexBeforeCreation: 1, + selectedWasPinned: false, + pinnedCount: 1, + totalCountAfterCreation: 5 + ) + XCTAssertEqual(endIndex, 4) + + let noSelectionIndex = SurfacePlacementSettings.insertionIndex( + placement: .afterCurrent, + selectedIndexBeforeCreation: nil, + selectedWasPinned: false, + pinnedCount: 0, + totalCountAfterCreation: 5 + ) + XCTAssertEqual(noSelectionIndex, 4) + } +} + final class WorkspaceWorkingDirectoryInheritanceSettingsTests: XCTestCase { func testDefaultsToEnabledWhenUnset() { let suiteName = "WorkspaceWorkingDirectoryInheritanceSettingsTests.Default.\(UUID().uuidString)" @@ -2404,6 +2496,318 @@ final class WorkspaceWorkingDirectoryInheritanceSettingsTests: XCTestCase { } } +@MainActor +final class WorkspaceSurfaceCreationPlacementTests: XCTestCase { + private func withSurfacePlacement(_ placement: NewSurfacePlacement, run body: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let key = SurfacePlacementSettings.placementKey + let previousValue = defaults.object(forKey: key) + defaults.set(placement.rawValue, forKey: key) + defer { + if let previousValue { + defaults.set(previousValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + try body() + } + + private func orderedPanelIds(in workspace: Workspace, paneId: PaneID) -> [UUID] { + workspace.bonsplitController + .tabs(inPane: paneId) + .compactMap { workspace.panelIdFromSurfaceId($0.id) } + } + + private func v2Result( + method: String, + params: [String: Any], + file: StaticString = #filePath, + line: UInt = #line + ) throws -> [String: Any] { + let request: [String: Any] = [ + "id": method, + "method": method, + "params": params + ] + let requestData = try JSONSerialization.data(withJSONObject: request) + let requestLine = try XCTUnwrap(String(data: requestData, encoding: .utf8), file: file, line: line) + let raw = TerminalController.shared.handleSocketLine(requestLine) + let responseData = try XCTUnwrap(raw.data(using: .utf8), file: file, line: line) + let envelope = try XCTUnwrap( + try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + raw, + file: file, + line: line + ) + XCTAssertEqual(envelope["ok"] as? Bool, true, raw, file: file, line: line) + return try XCTUnwrap(envelope["result"] as? [String: Any], raw, file: file, line: line) + } + + private func withBrowserEnabled(run body: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let key = BrowserAvailabilitySettings.disabledKey + let previousValue = defaults.object(forKey: key) + defaults.set(false, forKey: key) + defer { + if let previousValue { + defaults.set(previousValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + try body() + } + + func testEndPlacementAppendsNewTerminalSurfaceToPaneEnd() throws { + try withSurfacePlacement(.end) { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let secondPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + let thirdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + + workspace.focusPanel(firstPanelId) + let createdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + + XCTAssertEqual( + orderedPanelIds(in: workspace, paneId: paneId), + [firstPanelId, secondPanel.id, thirdPanel.id, createdPanel.id] + ) + XCTAssertEqual(workspace.focusedPanelId, createdPanel.id) + } + } + + func testContextMenuNewTerminalUsesEndPlacement() throws { + try withSurfacePlacement(.end) { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let secondPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + let thirdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + let firstTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(firstPanelId)) + let firstTab = try XCTUnwrap(workspace.bonsplitController.tab(firstTabId)) + + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .newTerminalToRight, + for: firstTab, + inPane: paneId + ) + + let order = orderedPanelIds(in: workspace, paneId: paneId) + let createdPanelId = try XCTUnwrap( + order.first { ![firstPanelId, secondPanel.id, thirdPanel.id].contains($0) } + ) + XCTAssertEqual(order, [firstPanelId, secondPanel.id, thirdPanel.id, createdPanelId]) + XCTAssertEqual(workspace.focusedPanelId, createdPanelId) + } + } + + func testContextMenuNewBrowserUsesAnchorProfileWithEndPlacement() throws { + try withSurfacePlacement(.end) { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let initialPanelId = try XCTUnwrap(workspace.focusedPanelId) + let anchorProfile = try makeTemporaryBrowserProfile(named: "ContextMenuAnchor") + let selectedProfile = try makeTemporaryBrowserProfile(named: "ContextMenuSelected") + let anchorBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: anchorProfile.id, + placementOverride: .end + ) + ) + let selectedBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: selectedProfile.id, + placementOverride: .end + ) + ) + let anchorTabId = try XCTUnwrap(workspace.surfaceIdFromPanelId(anchorBrowser.id)) + let anchorTab = try XCTUnwrap(workspace.bonsplitController.tab(anchorTabId)) + + workspace.focusPanel(selectedBrowser.id) + workspace.splitTabBar( + workspace.bonsplitController, + didRequestTabContextAction: .newBrowserToRight, + for: anchorTab, + inPane: paneId + ) + + let order = orderedPanelIds(in: workspace, paneId: paneId) + let createdPanelId = try XCTUnwrap( + order.first { ![initialPanelId, anchorBrowser.id, selectedBrowser.id].contains($0) } + ) + let createdBrowser = try XCTUnwrap(workspace.browserPanel(for: createdPanelId)) + XCTAssertEqual( + order, + [initialPanelId, anchorBrowser.id, selectedBrowser.id, createdPanelId] + ) + XCTAssertEqual( + createdBrowser.profileID, + anchorProfile.id, + "Expected context-menu browser creation to inherit the right-clicked tab profile, not the selected tab profile" + ) + XCTAssertEqual(workspace.focusedPanelId, createdPanelId) + } + } + + func testSocketNewBrowserRightUsesAnchorProfileWithEndPlacement() throws { + try withBrowserEnabled { + try withSurfacePlacement(.end) { + let manager = TabManager() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(nil) } + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let initialPanelId = try XCTUnwrap(workspace.focusedPanelId) + let anchorProfile = try makeTemporaryBrowserProfile(named: "SocketAnchor") + let selectedProfile = try makeTemporaryBrowserProfile(named: "SocketSelected") + let anchorBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: anchorProfile.id, + placementOverride: .end + ) + ) + let selectedBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: selectedProfile.id, + placementOverride: .end + ) + ) + + workspace.focusPanel(selectedBrowser.id) + let result = try v2Result( + method: "surface.action", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": anchorBrowser.id.uuidString, + "action": "new_browser_right", + "focus": true + ] + ) + + let createdIDRaw = try XCTUnwrap(result["created_surface_id"] as? String) + let createdID = try XCTUnwrap(UUID(uuidString: createdIDRaw)) + let createdBrowser = try XCTUnwrap(workspace.browserPanel(for: createdID)) + XCTAssertEqual( + orderedPanelIds(in: workspace, paneId: paneId), + [initialPanelId, anchorBrowser.id, createdID, selectedBrowser.id] + ) + XCTAssertEqual( + createdBrowser.profileID, + anchorProfile.id, + "Expected socket new-browser-right to inherit the anchor surface profile, not the selected surface profile" + ) + } + } + } + + func testSocketDuplicateBrowserUsesDuplicatedProfileWithEndPlacement() throws { + try withBrowserEnabled { + try withSurfacePlacement(.end) { + let manager = TabManager() + TerminalController.shared.setActiveTabManager(manager) + defer { TerminalController.shared.setActiveTabManager(nil) } + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let initialPanelId = try XCTUnwrap(workspace.focusedPanelId) + let anchorProfile = try makeTemporaryBrowserProfile(named: "DuplicateAnchor") + let selectedProfile = try makeTemporaryBrowserProfile(named: "DuplicateSelected") + let anchorBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: anchorProfile.id, + placementOverride: .end + ) + ) + let selectedBrowser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: selectedProfile.id, + placementOverride: .end + ) + ) + + workspace.focusPanel(selectedBrowser.id) + let result = try v2Result( + method: "surface.action", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": anchorBrowser.id.uuidString, + "action": "duplicate", + "focus": true + ] + ) + + let createdIDRaw = try XCTUnwrap(result["created_surface_id"] as? String) + let createdID = try XCTUnwrap(UUID(uuidString: createdIDRaw)) + let createdBrowser = try XCTUnwrap(workspace.browserPanel(for: createdID)) + XCTAssertEqual( + orderedPanelIds(in: workspace, paneId: paneId), + [initialPanelId, anchorBrowser.id, createdID, selectedBrowser.id] + ) + XCTAssertEqual( + createdBrowser.profileID, + anchorProfile.id, + "Expected socket duplicate to preserve the duplicated browser profile" + ) + } + } + } + + func testDefaultPlacementInsertsNewTerminalSurfaceAfterFocusedSurface() throws { + try withSurfacePlacement(.afterCurrent) { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let secondPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + let thirdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + + workspace.focusPanel(firstPanelId) + let createdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + + XCTAssertEqual( + orderedPanelIds(in: workspace, paneId: paneId), + [firstPanelId, createdPanel.id, secondPanel.id, thirdPanel.id] + ) + XCTAssertEqual(workspace.focusedPanelId, createdPanel.id) + } + } + + func testTopPlacementInsertsUnpinnedSurfaceAfterPinnedSurfaces() throws { + try withSurfacePlacement(.top) { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let firstPanelId = try XCTUnwrap(workspace.focusedPanelId) + let secondPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true, placementOverride: .end)) + let thirdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true, placementOverride: .end)) + + workspace.setPanelPinned(panelId: firstPanelId, pinned: true) + workspace.focusPanel(thirdPanel.id) + let createdPanel = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: true)) + + XCTAssertEqual( + orderedPanelIds(in: workspace, paneId: paneId), + [firstPanelId, createdPanel.id, secondPanel.id, thirdPanel.id] + ) + XCTAssertEqual(workspace.focusedPanelId, createdPanel.id) + } + } +} + @MainActor final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { private final class DetachedWorkspaceTestPanel: Panel { @@ -2641,7 +3045,6 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { } } - @MainActor final class WorkspaceCreationPlacementTests: XCTestCase { private final class SnapshotMutatingTabManager: TabManager { diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 60077d6421ed..a9cf4ea27a36 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -57,6 +57,7 @@ const settingsFileExample = `{ // "appearance": "dark", // "menuBarOnly": false, // "newWorkspacePlacement": "afterCurrent", + // "newSurfacePlacement": "afterCurrent", // "openSupportedFilesInCmux": true, // "workspaceInheritWorkingDirectory": true, // "iMessageMode": true diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b2b0d26429be..85c227a82b9b 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -228,6 +228,12 @@ "default": "afterCurrent", "description": "Where new workspaces are inserted in the sidebar." }, + "newSurfacePlacement": { + "type": "string", + "enum": ["top", "afterCurrent", "end"], + "default": "afterCurrent", + "description": "Where new surfaces are inserted in the current pane's tab strip." + }, "workspaceInheritWorkingDirectory": { "type": "boolean", "default": true,