Edit workspace files directly on web (#2270)

* feat(files): edit workspace files on web

Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge.

* feat(panels): surface and protect modified tabs

Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently.

* fix(tests): use portable fake timeout handle

* fix(files): harden editor conflict handling

Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface.

* fix(files): close editor concurrency gaps

Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads.

* test(files): expect read revision metadata
This commit is contained in:
Mohamed Boudra
2026-07-21 04:41:19 +08:00
committed by GitHub
parent 9292f58896
commit 4bda2dfea9
69 changed files with 3678 additions and 241 deletions
+33
View File
@@ -466,6 +466,7 @@ export const ar: TranslationResources = {
},
tabs: {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
loadingAgentTitle: "جارٍ تحميل عنوان الوكيل",
emptyPane: "لا توجد علامات تبويب في هذا الجزء.",
fallback: {
@@ -532,6 +533,13 @@ export const ar: TranslationResources = {
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
unsavedMessage:
"تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.",
closeWithoutSaving: "إغلاق بدون حفظ",
closePaneTitle: "إغلاق اللوحة؟",
bulkUnsaved:
"تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.",
close: "يغلق",
cancel: "يلغي",
archive: "أرشيف",
@@ -1445,6 +1453,25 @@ export const ar: TranslationResources = {
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
editor: {
fileSize: "حجم الملف {{size}}",
lines: "{{count}} سطر",
editorStatus: "حالة المحرر {{status}}",
unsavedChanges: "تغييرات غير محفوظة",
saving: "جارٍ الحفظ...",
saveFailed: "فشل الحفظ",
changedOnDisk: "تغيّر على القرص",
vimMode: "وضع Vim ‏{{mode}}",
cursor: "السطر {{line}}، العمود {{column}}",
preview: "معاينة",
source: "المصدر",
unavailableTitle: "الملف غير متاح على القرص",
conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.",
overwrite: "استبدال",
reload: "إعادة تحميل",
reloadTitle: "إعادة التحميل من القرص؟",
reloadMessage: "ستفقد تغييراتك المحلية.",
},
},
diff: {
changesLabel: "التغييرات",
@@ -1540,12 +1567,18 @@ export const ar: TranslationResources = {
sections: {
general: "عام",
appearance: "مظهر",
editor: "المحرر",
shortcuts: "الاختصارات",
integrations: "التكامل",
permissions: "الأذونات",
diagnostics: "التشخيص",
about: "عن",
},
editor: {
title: "المحرر",
vimKeybindings: "اختصارات Vim",
vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.",
},
hostSections: {
connections: "اتصالات",
agents: "Agents",
+32
View File
@@ -465,6 +465,7 @@ export const en = {
},
tabs: {
loading: "Loading...",
modified: "Unsaved changes",
loadingAgentTitle: "Loading agent title",
emptyPane: "No tabs in this pane.",
fallback: {
@@ -534,6 +535,12 @@ export const en = {
close: "Close",
cancel: "Cancel",
archive: "Archive",
unsavedTitle: "Unsaved changes",
unsavedMessage:
"This tab has changes that have not been saved. Closing it will discard the draft.",
closeWithoutSaving: "Close without saving",
closePaneTitle: "Close pane?",
bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.",
closeTerminalTitle: "Close terminal?",
closeTerminalMessage: "Any running process in this terminal will be stopped immediately.",
archiveRunningAgentTitle: "Archive running agent?",
@@ -1457,6 +1464,25 @@ export const en = {
binaryPreviewUnavailable: "Binary preview unavailable",
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
editor: {
fileSize: "File size {{size}}",
lines: "{{count}} lines",
editorStatus: "Editor status {{status}}",
unsavedChanges: "Unsaved changes",
saving: "Saving...",
saveFailed: "Save failed",
changedOnDisk: "Changed on disk",
vimMode: "Vim mode {{mode}}",
cursor: "Line {{line}}, column {{column}}",
preview: "Preview",
source: "Source",
unavailableTitle: "File unavailable on disk",
conflictDescription: "The local buffer was preserved. Choose which version to keep.",
overwrite: "Overwrite",
reload: "Reload",
reloadTitle: "Reload from disk?",
reloadMessage: "Your local changes will be lost.",
},
},
diff: {
changesLabel: "Changes",
@@ -1552,12 +1578,18 @@ export const en = {
sections: {
general: "General",
appearance: "Appearance",
editor: "Editor",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
diagnostics: "Diagnostics",
about: "About",
},
editor: {
title: "Editor",
vimKeybindings: "Vim keybindings",
vimHint: "Applies to source files on web and desktop.",
},
hostSections: {
connections: "Connections",
agents: "Agents",
+33
View File
@@ -470,6 +470,7 @@ export const es: TranslationResources = {
},
tabs: {
loading: "Cargando...",
modified: "Cambios sin guardar",
loadingAgentTitle: "Título del agente de carga",
emptyPane: "No hay pestañas en este panel.",
fallback: {
@@ -537,6 +538,13 @@ export const es: TranslationResources = {
failedToReloadAgent: "No se pudo recargar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
unsavedMessage:
"Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.",
closeWithoutSaving: "Cerrar sin guardar",
closePaneTitle: "¿Cerrar panel?",
bulkUnsaved:
"{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.",
close: "Cerca",
cancel: "Cancelar",
archive: "Archivo",
@@ -1488,6 +1496,25 @@ export const es: TranslationResources = {
binaryPreviewUnavailable: "Vista previa binaria no disponible",
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
editor: {
fileSize: "Tamaño {{size}}",
lines: "{{count}} líneas",
editorStatus: "Estado del editor: {{status}}",
unsavedChanges: "Cambios sin guardar",
saving: "Guardando...",
saveFailed: "Error al guardar",
changedOnDisk: "Cambiado en disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Línea {{line}}, columna {{column}}",
preview: "Vista previa",
source: "Código fuente",
unavailableTitle: "Archivo no disponible en disco",
conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.",
overwrite: "Sobrescribir",
reload: "Recargar",
reloadTitle: "¿Recargar desde el disco?",
reloadMessage: "Se perderán tus cambios locales.",
},
},
diff: {
changesLabel: "Cambios",
@@ -1583,12 +1610,18 @@ export const es: TranslationResources = {
sections: {
general: "General",
appearance: "Apariencia",
editor: "Editor",
shortcuts: "Atajos",
integrations: "Integraciones",
permissions: "Permisos",
diagnostics: "Diagnóstico",
about: "Acerca de",
},
editor: {
title: "Editor",
vimKeybindings: "Atajos de Vim",
vimHint: "Se aplica a archivos fuente en web y escritorio.",
},
hostSections: {
connections: "Conexiones",
agents: "Agents",
+33
View File
@@ -470,6 +470,7 @@ export const fr: TranslationResources = {
},
tabs: {
loading: "Chargement...",
modified: "Modifications non enregistrées",
loadingAgentTitle: "Titre d'agent de chargement",
emptyPane: "Aucun onglet dans ce volet.",
fallback: {
@@ -537,6 +538,13 @@ export const fr: TranslationResources = {
failedToReloadAgent: "Échec du rechargement de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
unsavedMessage:
"Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.",
closeWithoutSaving: "Fermer sans enregistrer",
closePaneTitle: "Fermer le volet?",
bulkUnsaved:
"{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.",
close: "Fermer",
cancel: "Annuler",
archive: "Archive",
@@ -1491,6 +1499,25 @@ export const fr: TranslationResources = {
binaryPreviewUnavailable: "Aperçu binaire indisponible",
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
editor: {
fileSize: "Taille {{size}}",
lines: "{{count}} lignes",
editorStatus: "État de l’éditeur : {{status}}",
unsavedChanges: "Modifications non enregistrées",
saving: "Enregistrement...",
saveFailed: "Échec de l’enregistrement",
changedOnDisk: "Modifié sur le disque",
vimMode: "Mode Vim {{mode}}",
cursor: "Ligne {{line}}, colonne {{column}}",
preview: "Aperçu",
source: "Source",
unavailableTitle: "Fichier indisponible sur le disque",
conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.",
overwrite: "Écraser",
reload: "Recharger",
reloadTitle: "Recharger depuis le disque ?",
reloadMessage: "Vos modifications locales seront perdues.",
},
},
diff: {
changesLabel: "Modifications",
@@ -1586,12 +1613,18 @@ export const fr: TranslationResources = {
sections: {
general: "Général",
appearance: "Apparence",
editor: "Éditeur",
shortcuts: "Raccourcis",
integrations: "Intégrations",
permissions: "Autorisations",
diagnostics: "Diagnostic",
about: "À propos",
},
editor: {
title: "Éditeur",
vimKeybindings: "Raccourcis Vim",
vimHint: "S’applique aux fichiers source sur le web et le bureau.",
},
hostSections: {
connections: "Relations",
agents: "Agents",
+31
View File
@@ -470,6 +470,7 @@ export const ja: TranslationResources = {
},
tabs: {
loading: "読み込み中...",
modified: "未保存の変更",
loadingAgentTitle: "エージェントタイトルを読み込み中",
emptyPane: "このペインにタブがありません。",
fallback: {
@@ -537,6 +538,11 @@ export const ja: TranslationResources = {
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
},
confirmations: {
unsavedTitle: "未保存の変更",
unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。",
closeWithoutSaving: "保存せずに閉じる",
closePaneTitle: "ペインを閉じますか?",
bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。",
close: "閉じる",
cancel: "キャンセル",
archive: "アーカイブ",
@@ -1463,6 +1469,25 @@ export const ja: TranslationResources = {
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
editor: {
fileSize: "ファイルサイズ {{size}}",
lines: "{{count}} 行",
editorStatus: "エディターの状態 {{status}}",
unsavedChanges: "未保存の変更",
saving: "保存中...",
saveFailed: "保存に失敗しました",
changedOnDisk: "ディスク上で変更されました",
vimMode: "Vim モード {{mode}}",
cursor: "{{line}} 行、{{column}} 列",
preview: "プレビュー",
source: "ソース",
unavailableTitle: "ディスク上のファイルを利用できません",
conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。",
overwrite: "上書き",
reload: "再読み込み",
reloadTitle: "ディスクから再読み込みしますか?",
reloadMessage: "ローカルの変更は失われます。",
},
},
diff: {
changesLabel: "変更",
@@ -1558,12 +1583,18 @@ export const ja: TranslationResources = {
sections: {
general: "一般",
appearance: "外観",
editor: "エディター",
shortcuts: "ショートカット",
integrations: "連携",
permissions: "権限",
diagnostics: "診断",
about: "アプリ情報",
},
editor: {
title: "エディター",
vimKeybindings: "Vim キーバインド",
vimHint: "Web とデスクトップのソースファイルに適用されます。",
},
hostSections: {
connections: "接続",
agents: "エージェント",
+32
View File
@@ -470,6 +470,7 @@ export const ptBR: TranslationResources = {
},
tabs: {
loading: "Carregando...",
modified: "Alterações não salvas",
loadingAgentTitle: "Carregando título do agente",
emptyPane: "Nenhuma aba neste painel.",
fallback: {
@@ -536,6 +537,12 @@ export const ptBR: TranslationResources = {
failedToReloadAgent: "Falha ao recarregar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.",
closeWithoutSaving: "Fechar sem salvar",
closePaneTitle: "Fechar painel?",
bulkUnsaved:
"{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.",
close: "Fechar",
cancel: "Cancelar",
archive: "Arquivar",
@@ -1475,6 +1482,25 @@ export const ptBR: TranslationResources = {
binaryPreviewUnavailable: "Prévia binária indisponível",
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
editor: {
fileSize: "Tamanho {{size}}",
lines: "{{count}} linhas",
editorStatus: "Status do editor: {{status}}",
unsavedChanges: "Alterações não salvas",
saving: "Salvando...",
saveFailed: "Falha ao salvar",
changedOnDisk: "Alterado no disco",
vimMode: "Modo Vim {{mode}}",
cursor: "Linha {{line}}, coluna {{column}}",
preview: "Prévia",
source: "Fonte",
unavailableTitle: "Arquivo indisponível no disco",
conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.",
overwrite: "Sobrescrever",
reload: "Recarregar",
reloadTitle: "Recarregar do disco?",
reloadMessage: "Suas alterações locais serão perdidas.",
},
},
diff: {
changesLabel: "Alterações",
@@ -1570,12 +1596,18 @@ export const ptBR: TranslationResources = {
sections: {
general: "Geral",
appearance: "Aparência",
editor: "Editor",
shortcuts: "Atalhos",
integrations: "Integrações",
permissions: "Permissões",
diagnostics: "Diagnósticos",
about: "Sobre",
},
editor: {
title: "Editor",
vimKeybindings: "Atalhos do Vim",
vimHint: "Aplica-se a arquivos-fonte na web e no desktop.",
},
hostSections: {
connections: "Conexões",
agents: "Agentes",
+33
View File
@@ -470,6 +470,7 @@ export const ru: TranslationResources = {
},
tabs: {
loading: "Загрузка...",
modified: "Несохранённые изменения",
loadingAgentTitle: "Название агента загрузки",
emptyPane: "На этой панели нет вкладок.",
fallback: {
@@ -536,6 +537,13 @@ export const ru: TranslationResources = {
failedToReloadAgent: "Не удалось перезагрузить агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
unsavedMessage:
"В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.",
closeWithoutSaving: "Закрыть без сохранения",
closePaneTitle: "Закрыть панель?",
bulkUnsaved:
"В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.",
close: "Закрывать",
cancel: "Отмена",
archive: "Архив",
@@ -1480,6 +1488,25 @@ export const ru: TranslationResources = {
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
editor: {
fileSize: "Размер файла {{size}}",
lines: "Строк: {{count}}",
editorStatus: "Состояние редактора: {{status}}",
unsavedChanges: "Несохранённые изменения",
saving: "Сохранение...",
saveFailed: "Не удалось сохранить",
changedOnDisk: "Изменён на диске",
vimMode: "Режим Vim {{mode}}",
cursor: "Строка {{line}}, столбец {{column}}",
preview: "Просмотр",
source: "Исходник",
unavailableTitle: "Файл недоступен на диске",
conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.",
overwrite: "Перезаписать",
reload: "Перезагрузить",
reloadTitle: "Перезагрузить с диска?",
reloadMessage: "Локальные изменения будут потеряны.",
},
},
diff: {
changesLabel: "Изменения",
@@ -1575,12 +1602,18 @@ export const ru: TranslationResources = {
sections: {
general: "Общий",
appearance: "Появление",
editor: "Редактор",
shortcuts: "Ярлыки",
integrations: "Интеграции",
permissions: "Разрешения",
diagnostics: "Диагностика",
about: "О",
},
editor: {
title: "Редактор",
vimKeybindings: "Клавиши Vim",
vimHint: "Применяется к исходным файлам в веб- и настольной версии.",
},
hostSections: {
connections: "Соединения",
agents: "Agents",
+31
View File
@@ -466,6 +466,7 @@ export const zhCN: TranslationResources = {
},
tabs: {
loading: "正在加载...",
modified: "未保存的更改",
loadingAgentTitle: "正在加载 Agent 标题",
emptyPane: "此窗格中没有标签。",
fallback: {
@@ -532,6 +533,11 @@ export const zhCN: TranslationResources = {
failedToReloadAgent: "重新加载 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。",
closeWithoutSaving: "不保存并关闭",
closePaneTitle: "关闭面板?",
bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。",
close: "关闭",
cancel: "取消",
archive: "归档",
@@ -1428,6 +1434,25 @@ export const zhCN: TranslationResources = {
binaryPreviewUnavailable: "二进制预览不可用",
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
editor: {
fileSize: "文件大小 {{size}}",
lines: "{{count}} 行",
editorStatus: "编辑器状态 {{status}}",
unsavedChanges: "未保存的更改",
saving: "正在保存...",
saveFailed: "保存失败",
changedOnDisk: "磁盘上的文件已更改",
vimMode: "Vim 模式 {{mode}}",
cursor: "第 {{line}} 行,第 {{column}} 列",
preview: "预览",
source: "源代码",
unavailableTitle: "磁盘上的文件不可用",
conflictDescription: "本地内容已保留。请选择要保留的版本。",
overwrite: "覆盖",
reload: "重新加载",
reloadTitle: "从磁盘重新加载?",
reloadMessage: "本地更改将丢失。",
},
},
diff: {
changesLabel: "更改",
@@ -1523,12 +1548,18 @@ export const zhCN: TranslationResources = {
sections: {
general: "通用",
appearance: "外观",
editor: "编辑器",
shortcuts: "快捷键",
integrations: "集成",
permissions: "权限",
diagnostics: "诊断",
about: "关于",
},
editor: {
title: "编辑器",
vimKeybindings: "Vim 键位",
vimHint: "适用于网页和桌面端的源文件。",
},
hostSections: {
connections: "连接",
agents: "Agents",