Merge pull request #2998 from mattv8/feat/move-session-existing-worktree

feat(sessions): move sessions to existing worktrees
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 11:37:21 +03:00
committed by GitHub
35 changed files with 3328 additions and 143 deletions
+22 -1
View File
@@ -2989,11 +2989,32 @@ export const dict = {
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden',
'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben',
'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.',
'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden',
'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch und verschiebt diese Sitzung samt Untersitzungen dorthin. Bei ungespeicherten Änderungen in der Quelle wählst du, ob sie mit verschoben werden.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Die Quelle hat ungespeicherte Änderungen',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Geänderte Dateien in diesem Worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode verfolgt diese Änderungen nach Verzeichnis, nicht nach Sitzung.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Verschiebt diese Sitzung und ihre Untersitzungen, ohne die Quelldateien zu verändern.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Überträgt die Änderungen im Sitzungsverzeichnis. Nicht committete und unversionierte Dateien verlassen die Quelle nach Erfolg.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Gemappte (staged) Änderungen bleiben in der Quelle und werden ans Ziel kopiert.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Die Übertragung kann fehlschlagen, wenn das Ziel eine andere Git-Basis verwendet.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Nur Sitzung verschieben',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Alle Quelländerungen verschieben',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.',
'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden',
'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben',
'gitView.header.updateBranch': 'Branch aktualisieren',
+22 -1
View File
@@ -537,11 +537,32 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Unshare',
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Move to worktree',
'sessions.sidebar.session.menu.newWorktree': 'New worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.',
'sessions.sidebar.session.moveToWorktree.main': 'Main worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded',
'sessions.sidebar.session.moveToWorktree.current': 'Current worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch and moves this session and its sub-sessions there. When the source has uncommitted changes, you choose whether to move them.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Source has uncommitted changes',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Changed files in this worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode tracks these changes by directory, not by session.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Move this session and its sub-sessions while leaving every source file unchanged.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfer changes under the session directory. Unstaged and untracked files leave the source after success.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Staged changes remain in the source and are copied to the destination.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'The transfer can fail when the destination uses a different Git base.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Move session only',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Move all source changes',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.',
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Dejar de compartir",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover a worktree",
"sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo",
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.",
"sessions.sidebar.session.moveToWorktree.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree actual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual y mueve allí esta sesión y sus subsesiones. Si la fuente tiene cambios sin confirmar, decides si se transfieren.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "La fuente tiene cambios sin confirmar",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Archivos modificados en este worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode rastrea estos cambios por directorio, no por sesión.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Mueve esta sesión y sus subsesiones dejando intacto cada archivo de la fuente.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfiere los cambios del directorio de la sesión. Los archivos sin confirmar y sin rastrear salen de la fuente tras el éxito.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Los cambios en el índice permanecen en la fuente y se copian al destino.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "La transferencia puede fallar si el destino usa una base de Git distinta.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover solo la sesión",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todos los cambios de la fuente",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.",
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
+22 -1
View File
@@ -368,11 +368,32 @@ export const dict = {
'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Déplacer vers un worktree',
'sessions.sidebar.session.menu.newWorktree': 'Nouveau worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.',
'sessions.sidebar.session.moveToWorktree.main': 'Worktree principal',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees',
'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et loption den créer un nouveau pour cette session.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle et y déplace cette session et ses sous-sessions. Si la source contient des modifications non validées, vous choisissez de les transférer ou non.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez lactivité en cours ou attendez sa fin.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'La source contient des modifications non validées',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Fichiers modifiés dans ce worktree : {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode suit ces modifications par répertoire, pas par session.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Déplace cette session et ses sous-sessions en laissant chaque fichier source inchangé.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfère les modifications du répertoire de la session. Les fichiers non indexés et non suivis quittent la source après succès.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Les modifications indexées restent dans la source et sont copiées vers la destination.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Le transfert peut échouer si la destination utilise une base Git différente.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Déplacer la session uniquement',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Déplacer toutes les modifications de la source',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source nont pas pu être vérifiées. Aucun worktree ni session na été modifié.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination na pas pu accepter les modifications de la source. La session et les modifications nont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.',
'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur',
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '共有解除',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動',
'sessions.sidebar.session.menu.newWorktree': '新しいworktree...',
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.main': 'メインworktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした',
'sessions.sidebar.session.moveToWorktree.current': '現在のworktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。',
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、このセッションとサブセッションをそこへ移動します。ソースに未コミットの変更がある場合は、移動するかどうかを選択します。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'ソースに未コミットの変更があります',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'このworktree内の変更されたファイル: {count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCodeはこれらの変更をセッションではなくディレクトリ単位で追跡します。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'ソースファイルを一切変更せずに、このセッションとサブセッションを移動します。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'セッションディレクトリ配下の変更を転送します。ステージされていない・追跡されていないファイルは成功後にソースを離れます。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'ステージ済みの変更はソースに残り、宛先へコピーされます。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '宛先が異なるGitベースを使用している場合、転送に失敗することがあります。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'セッションのみ移動',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'ソースの変更をすべて移動',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。',
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '공유 해제',
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동',
'sessions.sidebar.session.menu.newWorktree': '새 worktree...',
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.main': '메인 worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다',
'sessions.sidebar.session.moveToWorktree.current': '현재 worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다',
'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.',
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들어 이 세션과 하위 세션을 그곳으로 이동합니다. 원본에 커밋되지 않은 변경 사항이 있으면 이동 여부를 선택합니다.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
'sessions.sidebar.session.moveToWorktree.confirm.title': '원본에 커밋되지 않은 변경 사항이 있습니다',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '이 worktree에서 변경된 파일: {count}개.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode는 이 변경 사항을 세션이 아닌 디렉터리 기준으로 추적합니다.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '원본 파일은 그대로 둔 채 이 세션과 하위 세션을 이동합니다.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '세션 디렉터리 아래의 변경 사항을 전송합니다. 스테이지되지 않거나 추적되지 않은 파일은 성공 후 원본을 떠납니다.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '스테이지된 변경 사항은 원본에 남고 목적지로 복사됩니다.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '목적지가 다른 Git 베이스를 사용하면 전송이 실패할 수 있습니다.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '세션만 이동',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '원본 변경 사항 모두 이동',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.',
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
+22 -1
View File
@@ -331,11 +331,32 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree',
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Przenieś do worktree',
'sessions.sidebar.session.menu.newWorktree': 'Nowy worktree...',
'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree',
'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.',
'sessions.sidebar.session.moveToWorktree.main': 'Główny worktree',
'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...',
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree',
'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree',
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree',
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.',
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi i przenosi tam tę sesję wraz z podsesjami. Jeśli w źródle są niezacommitowane zmiany, decydujesz, czy je przenieść.',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.',
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Źródło ma niezacommitowane zmiany',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Zmienione pliki w tym worktree: {count}.',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode śledzi te zmiany według katalogu, a nie sesji.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Przenosi tę sesję i jej podsesje, pozostawiając każdy plik źródłowy bez zmian.',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Przenosi zmiany w katalogu sesji. Pliki niezacommitowane i nieśledzone opuszczają źródło po sukcesie.',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Zmiany w indeksie pozostają w źródle i są kopiowane do miejsca docelowego.',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Przeniesienie może się nie udać, gdy cel używa innej bazy Git.',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Przenieś tylko sesję',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Przenieś wszystkie zmiany ze źródła',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.',
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover para worktree",
"sessions.sidebar.session.menu.newWorktree": "Novo worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.",
"sessions.sidebar.session.moveToWorktree.main": "Worktree principal",
"sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees",
"sessions.sidebar.session.moveToWorktree.current": "Worktree atual",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual e move esta sessão e suas subsessões para lá. Quando a fonte tem alterações não commitadas, você escolhe se as transfere.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "A fonte tem alterações não commitadas",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Arquivos alterados neste worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "O OpenCode rastreia essas alterações por diretório, não por sessão.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Move esta sessão e suas subsessões deixando todos os arquivos da fonte inalterados.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfere as alterações no diretório da sessão. Arquivos não adicionados ao stage e não rastreados saem da fonte após o sucesso.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Alterações já no stage permanecem na fonte e são copiadas para o destino.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "A transferência pode falhar quando o destino usa uma base do Git diferente.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover apenas a sessão",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todas as alterações da fonte",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.",
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree",
"sessions.sidebar.session.menu.newWorktree": "Новий worktree...",
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
"sessions.sidebar.session.moveToWorktree.main": "Основний worktree",
"sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...",
"sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree",
"sessions.sidebar.session.moveToWorktree.current": "Поточний worktree",
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree",
"sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree",
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.",
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки та переміщує туди цю сесію і її підсесії. Якщо у джерелі є незакомічені зміни, ви обираєте, переносити їх чи ні.",
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
"sessions.sidebar.session.moveToWorktree.confirm.title": "У джерелі є незакомічені зміни",
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Змінені файли у цьому worktree: {count}.",
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode відстежує ці зміни за каталогом, а не за сесією.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Переносить цю сесію та її підсесії, не змінюючи жодного файла джерела.",
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Переносить зміни з каталогу сесії. Незакомічені та невідстежувані файли залишають джерело після успіху.",
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Закомічені в індекс зміни залишаються в джерелі та копіюються до призначення.",
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "Перенесення може не вдатися, якщо призначення використовує іншу базу Git.",
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Перенести лише сесію",
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Перенести всі зміни з джерела",
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати",
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.",
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.",
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
+22 -1
View File
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树',
'sessions.sidebar.session.menu.newWorktree': '新建工作树...',
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
'sessions.sidebar.session.moveToWorktree.main': '主工作树',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树',
'sessions.sidebar.session.moveToWorktree.current': '当前工作树',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树',
'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。',
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,并将此会话及其子会话移至其中。当源有未提交的更改时,由你选择是否一并转移。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
'sessions.sidebar.session.moveToWorktree.confirm.title': '源有未提交的更改',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作树中已更改的文件:{count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 按目录而非会话跟踪这些更改。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移动此会话及其子会话,同时保持每个源文件不变。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '转移会话目录下的更改。未暂存和未跟踪的文件在成功后离开源。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暂存的更改保留在源中,并复制到目的地。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '当目的地使用不同的 Git 基准时,转移可能失败。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '仅移动会话',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移动全部源更改',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。',
'sessions.sidebar.session.menu.runFusion': '运行融合',
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
+22 -1
View File
@@ -551,11 +551,32 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.session.menu.unshare': '取消分享',
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹',
'sessions.sidebar.session.menu.newWorktree': '新增工作樹...',
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
'sessions.sidebar.session.moveToWorktree.main': '主要工作樹',
'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...',
'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹',
'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹',
'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹',
'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹',
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。',
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,並將此工作階段及其子工作階段移至其中。當來源有未提交的變更時,由你選擇是否一併轉移。',
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
'sessions.sidebar.session.moveToWorktree.confirm.title': '來源有未提交的變更',
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作樹中已變更的檔案:{count}。',
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 依目錄而非工作階段追蹤這些變更。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移動此工作階段及其子工作階段,同時保持每個來源檔案不變。',
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '轉移工作階段目錄下的變更。未暫存與未追蹤的檔案在成功後離開來源。',
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暫存的變更保留在來源中,並複製到目的地。',
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '當目的地使用不同的 Git 基礎時,轉移可能失敗。',
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '僅移動工作階段',
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移動全部來源變更',
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。',
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。',
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { getGitStatus } from '@/lib/gitApi';
import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi';
import { normalizePath } from '@/lib/pathNormalization';
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
@@ -12,12 +12,60 @@ import type { WorktreeMetadata } from '@/types/worktree';
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
import { create } from 'zustand';
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
export type SessionTreeMoveMessages = {
success: string;
failure: string;
sourceVerificationFailed: string;
applyChangesFailed: string;
};
export type SessionTreeMoveIntent =
| {
kind: 'existing';
root: Session;
descendants: Session[];
sourceDirectory: string;
destination: WorktreeMetadata;
messages: SessionTreeMoveMessages;
}
| {
kind: 'quick';
root: Session;
descendants: Session[];
sourceDirectory: string;
messages: SessionTreeMoveMessages;
};
export type SessionTreeMoveConfirmation = {
intent: SessionTreeMoveIntent;
dirtyFileCount: number;
stagedFileCount: number;
};
type SessionMoveState = {
pendingSessionIds: Set<string>;
requestingSessionIds: Set<string>;
confirmation: SessionTreeMoveConfirmation | null;
};
const useSessionMoveState = create<SessionMoveState>(() => ({
pendingSessionIds: new Set(),
requestingSessionIds: new Set(),
confirmation: null,
}));
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId) || state.requestingSessionIds.has(sessionId));
export const useSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
useSessionMoveState((state) => state.confirmation);
export const getSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
useSessionMoveState.getState().confirmation;
const setSessionMoveConfirmation = (confirmation: SessionTreeMoveConfirmation | null): void => {
useSessionMoveState.setState((state) => (state.confirmation === confirmation ? state : { ...state, confirmation }));
};
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
useSessionMoveState.setState((state) => {
@@ -25,10 +73,29 @@ const setSessionMovePending = (sessionId: string, pending: boolean): void => {
const pendingSessionIds = new Set(state.pendingSessionIds);
if (pending) pendingSessionIds.add(sessionId);
else pendingSessionIds.delete(sessionId);
return { pendingSessionIds };
return { ...state, pendingSessionIds };
});
};
const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void => {
useSessionMoveState.setState((state) => {
if (state.requestingSessionIds.has(sessionId) === requesting) return state;
const requestingSessionIds = new Set(state.requestingSessionIds);
if (requesting) requestingSessionIds.add(sessionId);
else requestingSessionIds.delete(sessionId);
return { ...state, requestingSessionIds };
});
};
const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory';
const isApplyChangesError = (error: Error): boolean => {
// SAFETY: move failures originate from our own SDK/runtime layer, which may
// attach an optional numeric HTTP status to an Error instance.
const errorWithStatus = error as Error & { status?: number };
return errorWithStatus.status === 400 && error.message.includes(APPLY_CHANGES_MESSAGE);
};
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
try {
const status = await getGitStatus(directory, { mode: 'light' });
@@ -61,25 +128,51 @@ const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void
if (hasActiveSession) throw new Error('Session is not idle');
};
type RollbackFailure = {
sessionId: string;
error: Error;
};
const createIncompleteRollbackError = (moveError: Error, rollbackFailures: RollbackFailure[]): Error => {
const rollbackSummary = rollbackFailures
.map(({ sessionId, error }) => `${sessionId}: ${error.message}`)
.join(', ');
return new Error(
`Session move partially failed and could not be fully rolled back: ${moveError.message}. Rollback failures: ${rollbackSummary}`,
{ cause: { moveError, rollbackFailures } },
);
};
const isSessionBusyOrRetrying = (session: Session, directory: string): boolean => {
const status = getDirectoryState(directory)?.session_status[session.id]?.type;
return status === 'busy' || status === 'retry';
};
const rollbackMovedSessions = async (
sessions: Session[],
rootSessionId: string,
sourceDirectory: string,
worktreeDirectory: string,
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
): Promise<unknown[]> => {
const failures: unknown[] = [];
): Promise<RollbackFailure[]> => {
const failures: RollbackFailure[] = [];
for (const session of [...sessions].reverse()) {
if (isSessionBusyOrRetrying(session, worktreeDirectory)) {
failures.push({ sessionId: session.id, error: new Error('Session is not idle') });
continue;
}
try {
await moveSessionToDirectory(
session,
worktreeDirectory,
sourceDirectory,
session.id === rootSessionId,
false,
);
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
} catch (error) {
failures.push(error);
failures.push({
sessionId: session.id,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
return failures;
@@ -88,32 +181,36 @@ const rollbackMovedSessions = async (
const removeFailedWorktree = async (
project: ProjectRef,
worktree: WorktreeMetadata,
moveError: unknown,
moveError: Error,
): Promise<never> => {
try {
await removeProjectWorktree(project, worktree, { deleteLocalBranch: true });
} catch {
const message = moveError instanceof Error ? moveError.message : String(moveError);
throw new Error(`Session move failed and the new worktree could not be removed: ${message}`);
throw new Error(`Session move failed and the new worktree could not be removed: ${moveError.message}`);
}
throw moveError;
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
}): Promise<string> => {
const moveSessionTreeTransaction = async (
input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
moveChanges: boolean;
},
prepareDestination: () => Promise<{
directory: string;
metadata: WorktreeMetadata;
onMoveFailure?: (error: Error) => Promise<never>;
}>,
): Promise<string> => {
if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) {
throw new Error('Session move already in progress');
}
setSessionMovePending(input.root.id, true);
try {
const project = resolveProjectRef(input.sourceDirectory);
if (!project) throw new Error('Unable to find the project for this session');
const sessions = [input.root, ...input.descendants];
const sessions = [...input.descendants, input.root];
const previousMetadata = new Map(
sessions.map((session) => [
session.id,
@@ -122,59 +219,188 @@ const moveSessionTreeToQuickWorktree = async (input: {
);
assertSessionsIdle(sessions, input.sourceDirectory);
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
let destination: Awaited<ReturnType<typeof prepareDestination>> | null = null;
const moved: Session[] = [];
try {
await waitForWorktreeGitReady(worktree.path);
// Branch/status discovery and worktree creation can take long enough for a
// session to start running, so verify the whole tree again before moving.
assertSessionsIdle(sessions, input.sourceDirectory);
destination = await prepareDestination();
for (const [index, session] of sessions.entries()) {
// Transfer the checkout changes once with the root. Descendants only
// need their execution location updated.
await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0);
// Setup and earlier moves can take long enough for a not-yet-moved
// session to start running, so re-check the remaining source tree
// immediately before each move. The root moves last so no later
// descendant failure can require replaying a transferred patch.
assertSessionsIdle(sessions.slice(index), input.sourceDirectory);
await moveSessionToDirectory(
session,
input.sourceDirectory,
destination.directory,
session.id === input.root.id && input.moveChanges,
);
moved.push(session);
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
if (session.id === input.root.id) continue;
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
}
} catch (error) {
const moveError = error instanceof Error ? error : new Error(String(error));
const rollbackFailures = await rollbackMovedSessions(
moved,
input.root.id,
input.sourceDirectory,
worktree.path,
destination?.directory ?? input.sourceDirectory,
previousMetadata,
);
if (rollbackFailures.length > 0) {
throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`);
throw createIncompleteRollbackError(moveError, rollbackFailures);
}
return removeFailedWorktree(project, worktree, error);
if (destination?.onMoveFailure) {
return destination.onMoveFailure(moveError);
}
throw moveError;
}
useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata));
try {
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]);
} catch (error) {
// Direct action updates already reconciled both stores. Keep the move
// successful if this best-effort authoritative refresh is unavailable.
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
}
return worktree.path;
return destination.directory;
} finally {
setSessionMovePending(input.root.id, false);
}
};
export const startSessionTreeWorktreeMove = (input: {
export const moveSessionTreeToExistingWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
successMessage: string;
failureMessage: string;
}): void => {
void moveSessionTreeToQuickWorktree(input)
.then(() => toast.success(input.successMessage))
.catch((error) => toast.error(input.failureMessage, {
description: error instanceof Error ? error.message : String(error),
}));
destination: WorktreeMetadata;
moveChanges: boolean;
}): Promise<string> => {
const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory;
const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path;
if (normalizedSourceDirectory === normalizedDestinationDirectory) {
throw new Error('Source and destination are the same');
}
if (input.destination.worktreeStatus !== 'ready') {
throw new Error('Destination worktree is not ready');
}
return moveSessionTreeTransaction(input, async () => ({
directory: input.destination.path,
metadata: input.destination,
}));
};
const moveSessionTreeToQuickWorktree = async (input: {
root: Session;
descendants: Session[];
sourceDirectory: string;
moveChanges: boolean;
}): Promise<string> => {
return moveSessionTreeTransaction(input, async () => {
const project = resolveProjectRef(input.sourceDirectory);
if (!project) throw new Error('Unable to find the project for this session');
const sourceBranch = await checkIsGitRepository(input.sourceDirectory)
? await resolveSourceBranch(input.sourceDirectory, project.path)
: null;
const worktree = await createQuickWorktree(project, sourceBranch ? { startRef: sourceBranch } : {});
try {
await waitForWorktreeGitReady(worktree.path);
} catch (error) {
const setupError = error instanceof Error ? error : new Error(String(error));
return removeFailedWorktree(project, worktree, setupError);
}
return {
directory: worktree.path,
metadata: worktree,
onMoveFailure: (error) => removeFailedWorktree(project, worktree, error),
};
});
};
const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => {
const movePromise = intent.kind === 'existing'
? moveSessionTreeToExistingWorktree({
root: intent.root,
descendants: intent.descendants,
sourceDirectory: intent.sourceDirectory,
destination: intent.destination,
moveChanges,
})
: moveSessionTreeToQuickWorktree({
root: intent.root,
descendants: intent.descendants,
sourceDirectory: intent.sourceDirectory,
moveChanges,
});
void movePromise
.then(() => toast.success(intent.messages.success))
.catch((error) => {
const failure = error instanceof Error ? error : new Error(String(error));
toast.error(intent.messages.failure, {
description: moveChanges && isApplyChangesError(failure)
? intent.messages.applyChangesFailed
: failure.message,
});
});
};
export const cancelSessionTreeMove = (): void => {
const confirmation = getSessionTreeMoveConfirmation();
if (!confirmation) return;
setSessionMoveRequesting(confirmation.intent.root.id, false);
setSessionMoveConfirmation(null);
};
export const confirmSessionTreeMove = (moveChanges: boolean): void => {
const confirmation = getSessionTreeMoveConfirmation();
if (!confirmation) return;
const { intent } = confirmation;
setSessionMoveConfirmation(null);
setSessionMoveRequesting(intent.root.id, false);
executeSessionTreeMove(intent, moveChanges);
};
export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => {
const state = useSessionMoveState.getState();
if (state.confirmation) return;
if (state.pendingSessionIds.has(intent.root.id) || state.requestingSessionIds.has(intent.root.id)) return;
setSessionMoveRequesting(intent.root.id, true);
void (async () => {
try {
const isGitRepository = await checkIsGitRepository(intent.sourceDirectory);
if (!isGitRepository) {
setSessionMoveRequesting(intent.root.id, false);
executeSessionTreeMove(intent, false);
return;
}
const status = await getGitStatus(intent.sourceDirectory);
if (status.isClean) {
setSessionMoveRequesting(intent.root.id, false);
executeSessionTreeMove(intent, false);
return;
}
const stagedFileCount = status.files.filter((file) => {
const indexStatus = file.index.trim();
return indexStatus !== '' && indexStatus !== '?';
}).length;
setSessionMoveConfirmation({
intent,
dirtyFileCount: status.files.length,
stagedFileCount,
});
} catch {
toast.error(intent.messages.failure, {
description: intent.messages.sourceVerificationFailed,
});
setSessionMoveRequesting(intent.root.id, false);
}
})();
};
@@ -11,6 +11,8 @@ type WorktreeListEntry = {
const listCalls: string[] = [];
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
const listRejecters: Array<(reason: Error) => void> = [];
let listImplementation: ((directory: string) => Promise<WorktreeListEntry[]>) | undefined;
const createPayloads: unknown[] = [];
const validatePayloads: unknown[] = [];
const createdWorktree = {
@@ -78,8 +80,12 @@ mock.module('@/lib/gitApi', () => ({
worktree: {
list: (directory: string) => {
listCalls.push(directory);
return new Promise<WorktreeListEntry[]>((resolve) => {
if (listImplementation) {
return listImplementation(directory);
}
return new Promise<WorktreeListEntry[]>((resolve, reject) => {
listResolvers.push(resolve);
listRejecters.push((reason: Error) => reject(reason));
});
},
create: mock((_directory: string, payload: unknown) => {
@@ -118,6 +124,8 @@ describe('worktreeManager list invalidation', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
listRejecters.length = 0;
listImplementation = undefined;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
@@ -152,6 +160,143 @@ describe('worktreeManager list invalidation', () => {
expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']);
});
test('forced refresh bypasses a fresh cached result', async () => {
const project = { id: 'project-force-cache', path: '/repo-force-cache' };
const initialListing = listProjectWorktrees(project);
await waitForListCallCount(1);
listResolvers[0]([]);
const initialResult = await initialListing;
expect(initialResult).toEqual([]);
const cachedResult = await listProjectWorktrees(project);
expect(cachedResult).toEqual([]);
expect(listCalls).toEqual(['/repo-force-cache']);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
const refreshedCachedResult = await listProjectWorktrees(project);
expect(refreshedCachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
});
test('forced refresh starts a new request instead of joining an older in-flight list', async () => {
const project = { id: 'project-force-inflight', path: '/repo-force-inflight' };
const initialListing = listProjectWorktrees(project);
await waitForListCallCount(1);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
listResolvers[0]([]);
await waitForListCallCount(3);
listResolvers[2]([createdWorktree]);
const initialResult = await initialListing;
expect(initialResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toEqual([
'/repo-force-inflight',
'/repo-force-inflight',
'/repo-force-inflight',
]);
});
test('older completions do not replace a forced refresh result with stale topology', async () => {
const project = { id: 'project-force-stale', path: '/repo-force-stale' };
void listProjectWorktrees(project);
await waitForListCallCount(1);
const forcedListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
listResolvers[1]([createdWorktree]);
const forcedResult = await forcedListing;
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
listResolvers[0]([{ path: '/repo-stale', branch: 'stale', name: 'stale' }]);
await waitForListCallCount(3);
const cachedResult = await listProjectWorktrees(project);
expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
});
test('rejects when git worktree listing fails', async () => {
const project = { id: 'project-force-failure', path: '/repo-force-failure' };
const listing = listProjectWorktrees(project);
await waitForListCallCount(1);
listRejecters[0](new Error('git failed'));
await expect(listing).rejects.toThrow('git failed');
});
test('rejects sustained invalidation explicitly, preserves the last cached result, and allows a later retry', async () => {
const project = { id: 'project-force-convergence', path: '/repo-force-convergence' };
const oldWorktree = [{ path: '/repo-old', branch: 'old', name: 'old' } satisfies WorktreeListEntry];
const scriptedResolvers = new Map<number, (value: WorktreeListEntry[]) => void>();
let recoveryReadsAllowed = false;
listImplementation = () => {
const callNumber = listCalls.length;
if (callNumber === 8 && !recoveryReadsAllowed) {
return Promise.reject(new Error('unexpected extra read'));
}
return new Promise<WorktreeListEntry[]>((resolve) => {
scriptedResolvers.set(callNumber, resolve);
});
};
const seededListing = listProjectWorktrees(project);
await waitForListCallCount(1);
scriptedResolvers.get(1)?.(oldWorktree);
expect((await seededListing).map((entry) => entry.path)).toEqual(['/repo-old']);
const unstableListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(2);
const forcedRefreshA = listProjectWorktrees(project, { force: true });
await waitForListCallCount(3);
scriptedResolvers.get(3)?.([createdWorktree]);
expect((await forcedRefreshA).map((entry) => entry.path)).toEqual(['/repo-feature']);
scriptedResolvers.get(2)?.([{ path: '/repo-stale-a', branch: 'stale-a', name: 'stale-a' }]);
await waitForListCallCount(4);
const forcedRefreshB = listProjectWorktrees(project, { force: true });
await waitForListCallCount(5);
scriptedResolvers.get(5)?.([createdWorktree]);
expect((await forcedRefreshB).map((entry) => entry.path)).toEqual(['/repo-feature']);
scriptedResolvers.get(4)?.([{ path: '/repo-stale-b', branch: 'stale-b', name: 'stale-b' }]);
await waitForListCallCount(6);
const forcedRefreshC = listProjectWorktrees(project, { force: true });
await waitForListCallCount(7);
scriptedResolvers.get(7)?.([createdWorktree]);
expect((await forcedRefreshC).map((entry) => entry.path)).toEqual(['/repo-feature']);
scriptedResolvers.get(6)?.([{ path: '/repo-stale-c', branch: 'stale-c', name: 'stale-c' }]);
await expect(unstableListing).rejects.toThrow('Worktree list did not converge');
expect(listCalls).toHaveLength(7);
const cachedResult = await listProjectWorktrees(project);
expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
expect(listCalls).toHaveLength(7);
recoveryReadsAllowed = true;
const recoveredListing = listProjectWorktrees(project, { force: true });
await waitForListCallCount(8);
scriptedResolvers.get(8)?.([createdWorktree]);
expect((await recoveredListing).map((entry) => entry.path)).toEqual(['/repo-feature']);
});
test('marks fast-created worktrees pending until bootstrap settles', async () => {
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
preferredName: 'feature',
@@ -389,6 +534,7 @@ describe('worktreeManager fork remote payload wiring', () => {
beforeEach(() => {
listCalls.length = 0;
listResolvers.length = 0;
listRejecters.length = 0;
createPayloads.length = 0;
validatePayloads.length = 0;
bootstrapWatcherCalls.length = 0;
@@ -374,9 +374,10 @@ export const partitionWorktreesByRegisteredProject = (
// Cache worktree listings to avoid repeated git worktree list + rev-parse calls
const _worktreeListCache = new Map<string, { value: WorktreeMetadata[]; at: number }>();
const _worktreeListInflight = new Map<string, Promise<WorktreeMetadata[]>>();
const _worktreeListInflight = new Map<string, { generation: number; promise: Promise<WorktreeMetadata[]> }>();
const _worktreeListGeneration = new Map<string, number>();
const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds
const WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS = 3;
const getWorktreeListGeneration = (projectDirectory: string): number => {
return _worktreeListGeneration.get(projectDirectory) ?? 0;
@@ -391,7 +392,7 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
const normalizedProjectDirectory = normalizePath(projectDirectory);
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
const worktrees = await git.worktree.list(projectDirectory);
const results: WorktreeMetadata[] = worktrees
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
.map((entry) => {
@@ -424,38 +425,64 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
});
};
const readStableProjectWorktrees = async (projectDirectory: string): Promise<WorktreeMetadata[]> => {
while (true) {
const readStableProjectWorktrees = async (
projectDirectory: string,
minimumGeneration = getWorktreeListGeneration(projectDirectory),
): Promise<WorktreeMetadata[]> => {
for (let attempt = 0; attempt < WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS; attempt += 1) {
const generation = getWorktreeListGeneration(projectDirectory);
const worktrees = await readProjectWorktrees(projectDirectory);
if (generation === getWorktreeListGeneration(projectDirectory)) {
if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) {
_worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() });
return worktrees;
}
}
throw new Error(
`Worktree list did not converge after ${WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS} attempts`
);
};
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise<WorktreeMetadata[]> {
const projectDirectory = normalizePath(project.path);
const force = options?.force === true;
const previousCache = force ? _worktreeListCache.get(projectDirectory) : undefined;
if (force) {
invalidateWorktreeList(projectDirectory);
}
const generation = getWorktreeListGeneration(projectDirectory);
// Return cached if fresh
const cached = _worktreeListCache.get(projectDirectory);
if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
return cached.value;
}
// Dedup in-flight requests
const inflight = _worktreeListInflight.get(projectDirectory);
if (inflight) return inflight;
if (inflight && inflight.generation === generation) return inflight.promise;
const promise = readStableProjectWorktrees(projectDirectory).finally(() => {
if (_worktreeListInflight.get(projectDirectory) === promise) {
_worktreeListInflight.delete(projectDirectory);
}
});
const promise = readStableProjectWorktrees(projectDirectory, generation)
.catch((error) => {
if (
previousCache
&& !_worktreeListCache.has(projectDirectory)
&& getWorktreeListGeneration(projectDirectory) === generation
) {
_worktreeListCache.set(projectDirectory, previousCache);
}
throw error;
})
.finally(() => {
if (_worktreeListInflight.get(projectDirectory)?.promise === promise) {
_worktreeListInflight.delete(projectDirectory);
}
});
_worktreeListInflight.set(projectDirectory, promise);
_worktreeListInflight.set(projectDirectory, { generation, promise });
return promise;
}