diff --git a/services/web/frontend/stylesheets/components/dropdown-menu.scss b/services/web/frontend/stylesheets/components/dropdown-menu.scss
index 4b7c95b71c..f93b9ab762 100644
--- a/services/web/frontend/stylesheets/components/dropdown-menu.scss
+++ b/services/web/frontend/stylesheets/components/dropdown-menu.scss
@@ -160,6 +160,12 @@ $dropdown-item-min-height: 36px;
color: var(--dropdown-text-active);
}
+ &.disabled,
+ &[aria-disabled='true'] {
+ color: var(--content-disabled);
+ pointer-events: none;
+ }
+
&.btn-link {
text-decoration: none;
diff --git a/services/web/frontend/stylesheets/pages/editor/file-tree.scss b/services/web/frontend/stylesheets/pages/editor/file-tree.scss
index ae4a1ed4c0..66dc8a65ed 100644
--- a/services/web/frontend/stylesheets/pages/editor/file-tree.scss
+++ b/services/web/frontend/stylesheets/pages/editor/file-tree.scss
@@ -4,6 +4,7 @@
--file-tree-item-color: var(--content-primary-dark);
--file-tree-bg: var(--bg-dark-primary);
--file-tree-item-selected-color: var(--green-10);
+ --file-tree-item-disabled-color: var(--content-disabled-dark);
--file-tree-item-dragging-bg: var(--green-40);
--file-tree-item-dragging-color: var(--content-primary-dark);
--file-tree-item-icon-dragging-color: var(--file-tree-item-dragging-color);
@@ -20,6 +21,7 @@
--file-tree-item-color: var(--content-primary);
--file-tree-bg: var(--white);
--file-tree-item-selected-color: var(--green-70);
+ --file-tree-item-disabled-color: var(--content-disabled);
--file-tree-item-dragging-bg: var(--green-60);
--file-tree-item-dragging-color: var(--white);
--file-tree-item-icon-dragging-color: var(--file-tree-item-dragging-color);
@@ -91,6 +93,11 @@
&:active {
color: var(--file-tree-expand-button-color);
}
+
+ &[disabled],
+ &:disabled {
+ border-color: transparent;
+ }
}
.file-tree-folder-list {
@@ -503,6 +510,13 @@
}
}
}
+
+ ul.file-tree-list li.entity-disabled > .entity > .entity-name {
+ &,
+ & .file-tree-icon {
+ color: var(--file-tree-item-disabled-color) !important;
+ }
+ }
}
// while dragging, the previously selected item gets no highlight
diff --git a/services/web/frontend/stylesheets/pages/editor/tabs.scss b/services/web/frontend/stylesheets/pages/editor/tabs.scss
index 0f27e7ba50..e9822cdc1d 100644
--- a/services/web/frontend/stylesheets/pages/editor/tabs.scss
+++ b/services/web/frontend/stylesheets/pages/editor/tabs.scss
@@ -162,6 +162,14 @@
font-style: italic;
}
+ &.tab-disabled {
+ .editor-file-tab-content,
+ .editor-file-tab-icon .file-tree-icon,
+ .editor-file-tab-icon .linked-file-highlight {
+ color: var(--content-disabled-themed);
+ }
+ }
+
.editor-file-tab-action {
display: flex;
align-items: center;
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx b/services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx
index 975e02d76d..0813ea93d1 100644
--- a/services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-doc.spec.tsx
@@ -1,5 +1,6 @@
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
import { EditorProviders } from '../../../helpers/editor-providers'
+import { makeEditorManagerProviderWithStaleDocs } from '../../ide-react/helpers/editor-manager-provider-with-stale-docs'
import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('
', function () {
@@ -74,4 +75,47 @@ describe('
', function () {
cy.findByRole('treeitem').click({ ctrlKey: true, cmdKey: true })
cy.findByRole('treeitem', { selected: true })
})
+
+ describe('Network stall', function () {
+ const rootFolder = [
+ {
+ _id: 'root-folder-id',
+ name: 'rootFolder',
+ docs: [{ _id: '123abc' }],
+ fileRefs: [],
+ folders: [],
+ },
+ ]
+
+ beforeEach(function () {
+ cy.clock()
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-splitTestVariants', {
+ 'intermittent-connection-improvements': 'enabled',
+ })
+ })
+ cy.then(() => {
+ const now = performance.now()
+ cy.mount(
+
+
+
+
+
+ )
+ })
+ })
+
+ it('disables doc when saving is stalled', function () {
+ cy.tick(1000)
+ cy.findByRole('treeitem').should('have.attr', 'aria-disabled', 'true')
+ })
+ })
})
diff --git a/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
index a361cfd080..f6ab758816 100644
--- a/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
+++ b/services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx
@@ -1,5 +1,6 @@
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
import { EditorProviders } from '../../../helpers/editor-providers'
+import { makeEditorManagerProviderWithStaleDocs } from '../../ide-react/helpers/editor-manager-provider-with-stale-docs'
import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('
', function () {
@@ -55,4 +56,39 @@ describe('
', function () {
cy.findAllByRole('button', { name: 'Upload' })
cy.findAllByRole('button', { name: 'Close' })
})
+
+ describe('Network stall', function () {
+ beforeEach(function () {
+ cy.clock()
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-splitTestVariants', {
+ 'intermittent-connection-improvements': 'enabled',
+ })
+ })
+ cy.then(() => {
+ const now = performance.now()
+ cy.mount(
+
+
+
+
+
+ )
+ })
+ })
+
+ it('disables action buttons when saving is stalled', function () {
+ cy.tick(1000)
+ cy.findByRole('button', { name: 'New file' }).should('be.disabled')
+ cy.findByRole('button', { name: 'New folder' }).should('be.disabled')
+ cy.findByRole('button', { name: 'Upload' }).should('be.disabled')
+ })
+ })
})
diff --git a/services/web/test/frontend/features/ide-react/components/toolbar/offline-indicator.spec.tsx b/services/web/test/frontend/features/ide-react/components/toolbar/offline-indicator.spec.tsx
index 58b7490578..e2a4af90af 100644
--- a/services/web/test/frontend/features/ide-react/components/toolbar/offline-indicator.spec.tsx
+++ b/services/web/test/frontend/features/ide-react/components/toolbar/offline-indicator.spec.tsx
@@ -20,6 +20,7 @@ const defaultIdeReactContextValue = {
const defaultUnsavedDocsContextValue = {
unsavedDocs: new Map
(),
isLocked: false,
+ isSavingStalled: false,
}
const mount = ({
diff --git a/services/web/test/frontend/features/ide-react/components/unsaved-docs/unsaved-docs.spec.tsx b/services/web/test/frontend/features/ide-react/components/unsaved-docs/unsaved-docs.spec.tsx
index 53fbe49671..a01bec2a01 100644
--- a/services/web/test/frontend/features/ide-react/components/unsaved-docs/unsaved-docs.spec.tsx
+++ b/services/web/test/frontend/features/ide-react/components/unsaved-docs/unsaved-docs.spec.tsx
@@ -8,6 +8,7 @@ import { SplitTestProvider } from '@/shared/context/split-test-context'
const defaultUnsavedDocsContextValue = {
unsavedDocs: new Map([['doc1', 16]]),
isLocked: false,
+ isSavingStalled: false,
}
const mockSocket = {
diff --git a/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx b/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx
index 132990caa1..3bddf85871 100644
--- a/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx
+++ b/services/web/test/frontend/features/ide-react/context/unsaved-docs-context.spec.tsx
@@ -48,6 +48,7 @@ function mount(
projectJoined: true,
permissionsLevel: 'owner' as PermissionsLevel,
setPermissionsLevel: onSetPermissionsLevel,
+ outOfSync: false,
setOutOfSync: () => {},
}))
diff --git a/services/web/test/frontend/features/ide-react/helpers/editor-manager-provider-with-stale-docs.tsx b/services/web/test/frontend/features/ide-react/helpers/editor-manager-provider-with-stale-docs.tsx
new file mode 100644
index 0000000000..7ec739cd2a
--- /dev/null
+++ b/services/web/test/frontend/features/ide-react/helpers/editor-manager-provider-with-stale-docs.tsx
@@ -0,0 +1,54 @@
+import React, { type FC, type PropsWithChildren } from 'react'
+import {
+ EditorManager,
+ EditorManagerContext,
+} from '@/features/ide-react/context/editor-manager-context'
+
+export function makeEditorManagerProviderWithStaleDocs(
+ pendingAt: number,
+ stubs?: {
+ openDocWithId?: any
+ openDoc?: any
+ openFileWithId?: any
+ }
+) {
+ const staleDocs = [
+ {
+ doc_id: 'stale-doc',
+ getInflightOpCreatedAt: () => undefined,
+ getPendingOpCreatedAt: () => pendingAt,
+ },
+ ]
+ const EditorManagerProviderWithStaleDocs: FC = ({
+ children,
+ }) => {
+ const value = {
+ getEditorType: () => null,
+ getCurrentDocValue: () => null,
+ getCurrentDocumentId: () => null,
+ setIgnoringExternalUpdates: () => {},
+ openDocWithId: stubs?.openDocWithId ?? cy.stub().resolves(),
+ openDoc: stubs?.openDoc ?? cy.stub().resolves(),
+ openDocs: {
+ awaitBufferedOps: cy.stub().resolves(),
+ unsavedDocs: () => staleDocs,
+ hasUnsavedChanges: () => true,
+ getUnsavedOpsSize: () => ({
+ pendingOpsLength: 0,
+ inflightOpsLength: 0,
+ }),
+ } as any,
+ openFileWithId: stubs?.openFileWithId ?? cy.stub(),
+ openInitialDoc: cy.stub().resolves(),
+ isLoading: false,
+ jumpToLine: () => {},
+ debugTimers: { current: {} },
+ } as unknown as EditorManager
+ return (
+
+ {children}
+
+ )
+ }
+ return EditorManagerProviderWithStaleDocs
+}
diff --git a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx
index ae0b2f1d1b..87641b3bb8 100644
--- a/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx
+++ b/services/web/test/frontend/features/source-editor/components/tabs.spec.tsx
@@ -1,5 +1,6 @@
import React, { FC, useEffect, useRef, useState } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
+import { makeEditorManagerProviderWithStaleDocs } from '../../ide-react/helpers/editor-manager-provider-with-stale-docs'
import { TabsContainer } from '../../../../../frontend/js/features/source-editor/components/tabs/tabs-container'
import {
FileTreeDocumentFindResult,
@@ -1177,6 +1178,79 @@ describe('File Tabs', function () {
})
})
+ describe('Network stall', function () {
+ function mountWithStaleDocs() {
+ cy.then(() => {
+ const now = performance.now()
+ const openDocWithId = cy.stub().as('openDocWithId').resolves()
+ const openDoc = cy.stub().as('openDoc').resolves()
+ const openFileWithId = cy.stub().as('openFileWithId')
+ cy.mount(
+
+
+
+
+ )
+ })
+ }
+
+ beforeEach(function () {
+ cy.clock()
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-splitTestVariants', {
+ 'editor-tabs': 'enabled',
+ 'intermittent-connection-improvements': 'enabled',
+ })
+ })
+ mountWithStaleDocs()
+ })
+
+ it('disables tabs when saving is stalled', function () {
+ cy.then(() => selectDoc(DOC_IDS.main))
+ cy.tick(1000)
+ cy.findByRole('tab', { name: /main\.tex/ }).should(
+ 'have.attr',
+ 'aria-disabled',
+ 'true'
+ )
+ })
+
+ it('does not switch the active tab when clicking a disabled tab', function () {
+ cy.then(() => selectDoc(DOC_IDS.main))
+ cy.then(() => selectDoc(DOC_IDS.intro))
+ cy.tick(1000)
+ cy.get('@openDocWithId').invoke('resetHistory')
+ cy.findByRole('tab', { name: /main\.tex/ }).click()
+ cy.get('@openDocWithId').should('not.have.been.called')
+ })
+
+ it('does not disable tabs when the feature flag is off', function () {
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-splitTestVariants', {
+ 'editor-tabs': 'enabled',
+ })
+ })
+ mountWithStaleDocs()
+ cy.then(() => selectDoc(DOC_IDS.main))
+ cy.tick(1000)
+ cy.findByRole('tab', { name: /main\.tex/ }).should(
+ 'not.have.attr',
+ 'aria-disabled',
+ 'true'
+ )
+ })
+ })
+
describe('SplitTestBadge', function () {
it('renders the labs badge icon in the tabs container', function () {
cy.window().then(win => {
diff --git a/services/web/test/frontend/helpers/editor-providers.tsx b/services/web/test/frontend/helpers/editor-providers.tsx
index 7c7c4ca37c..95ac58a7d1 100644
--- a/services/web/test/frontend/helpers/editor-providers.tsx
+++ b/services/web/test/frontend/helpers/editor-providers.tsx
@@ -393,6 +393,7 @@ const makeIdeReactProvider = (
projectJoined: true,
permissionsLevel: scope.permissionsLevel as PermissionsLevel,
setPermissionsLevel: () => {},
+ outOfSync: false,
setOutOfSync: () => {},
}))