diff --git a/e2e-tests/fixtures/WorkspaceSaveConflict.ts b/e2e-tests/fixtures/WorkspaceSaveConflict.ts new file mode 100644 index 0000000000..87aa56959f --- /dev/null +++ b/e2e-tests/fixtures/WorkspaceSaveConflict.ts @@ -0,0 +1,76 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +/** + * Page Object Model for the workspace save-conflict modal (`WorkspaceSaveConflictModal`), + * shown when a save is rejected by the optimistic-concurrency check (`412`). Covers both + * the "conflict" variant (the file changed underneath) and the "deleted" variant (the file + * was deleted/moved underneath). Selectors are scoped to the modal root. + */ +export class WorkspaceSaveConflict { + cancelButton!: Locator; + conflictTitle!: Locator; + deletedTitle!: Locator; + discardButton!: Locator; + mineHeader!: Locator; + modal!: Locator; + recreateButton!: Locator; + takeMineButton!: Locator; + takeTheirsButton!: Locator; + theirsHeader!: Locator; + + constructor(public page: Page) { + this.updatePage(page); + } + + async cancel(): Promise { + await this.cancelButton.click(); + await expect(this.modal).toBeHidden(); + } + + async discardAndClose(): Promise { + await this.discardButton.click(); + await expect(this.modal).toBeHidden(); + } + + async recreate(): Promise { + await this.recreateButton.click(); + await expect(this.modal).toBeHidden(); + } + + async takeMine(): Promise { + await this.takeMineButton.click(); + await expect(this.modal).toBeHidden(); + } + + async takeTheirs(): Promise { + await this.takeTheirsButton.click(); + await expect(this.modal).toBeHidden(); + } + + updatePage(page: Page): void { + this.page = page; + this.modal = page.locator('#modal-container'); + this.conflictTitle = this.modal.getByText('This file was changed by someone else'); + this.deletedTitle = this.modal.getByText('File deleted or moved'); + this.theirsHeader = this.modal.getByText('Theirs (server)'); + this.mineHeader = this.modal.getByText('Mine (your edits)'); + this.takeTheirsButton = this.modal.getByRole('button', { name: 'Keep theirs' }); + this.takeMineButton = this.modal.getByRole('button', { name: 'Keep mine' }); + this.cancelButton = this.modal.getByRole('button', { name: 'Keep editing' }); + this.recreateButton = this.modal.getByRole('button', { name: 'Recreate file' }); + this.discardButton = this.modal.getByRole('button', { name: 'Discard & close' }); + } + + /** Wait for the "conflict" variant (read-only diff of theirs vs mine). */ + async waitForConflict(): Promise { + await expect(this.conflictTitle).toBeVisible({ timeout: 15000 }); + await expect(this.theirsHeader).toBeVisible(); + await expect(this.mineHeader).toBeVisible(); + } + + /** Wait for the "deleted/moved" variant (single read-only pane of mine). */ + async waitForDeleted(): Promise { + await expect(this.deletedTitle).toBeVisible({ timeout: 15000 }); + } +} diff --git a/e2e-tests/tests/workspace-edit-protection.test.ts b/e2e-tests/tests/workspace-edit-protection.test.ts new file mode 100644 index 0000000000..5b896d5455 --- /dev/null +++ b/e2e-tests/tests/workspace-edit-protection.test.ts @@ -0,0 +1,233 @@ +import test, { expect } from '@playwright/test'; +import { Dictionaries } from '../fixtures/Dictionaries.js'; +import { Parcels } from '../fixtures/Parcels.js'; +import { Workspace } from '../fixtures/Workspace.js'; +import { WorkspaceSaveConflict } from '../fixtures/WorkspaceSaveConflict.js'; +import { Workspaces } from '../fixtures/Workspaces.js'; +import { setupTest, teardownTest, type BrowserSetupResult } from '../utilities/api.js'; +import { generateRandomName } from '../utilities/helpers.js'; + +// Simultaneous-edit protection: a save is rejected (412) when the file changed underneath the +// editor since it was opened. We simulate the "other editor" with a second browser context for +// the same 'test' user editing the same workspace file. + +let setup: BrowserSetupResult; // primary editor — the one under test +let setupOther: BrowserSetupResult; // concurrent editor — changes the file first + +let dictionaries: Dictionaries; +let parcels: Parcels; +let workspaces: Workspaces; +let workspace: Workspace; // primary page +let otherWorkspace: Workspace; // concurrent page (same workspace) +let conflict: WorkspaceSaveConflict; +let workspaceId: string; +let workspaceName: string; + +test.beforeAll(async ({ baseURL, browser }) => { + test.setTimeout(120000); + + setup = await setupTest(browser, { model: false }); + dictionaries = new Dictionaries(setup.page); + parcels = new Parcels(setup.page); + workspaces = new Workspaces(setup.page, parcels, baseURL); + + await dictionaries.goto(); + await dictionaries.createCommandDictionary(); + await parcels.goto(); + await parcels.createParcel(dictionaries.commandDictionaryName, baseURL); + + await workspaces.goto(); + workspaceId = await workspaces.createWorkspace(); + workspaceName = workspaces.workspaceName; + workspace = new Workspace(setup.page, workspaceId, workspaceName, baseURL); + conflict = new WorkspaceSaveConflict(setup.page); + + // Second context (same 'test' user) editing the same workspace concurrently. + setupOther = await setupTest(browser, { model: false }); + otherWorkspace = new Workspace(setupOther.page, workspaceId, workspaceName, baseURL); + + await workspace.goto(); +}); + +test.afterAll(async () => { + await workspaces.goto(); + await workspaces.deleteWorkspace(workspaceName); + await parcels.goto(); + await parcels.deleteParcel(); + await dictionaries.goto(); + await dictionaries.deleteCommandDictionary(); + + await teardownTest(setup); + await teardownTest(setupOther); +}); + +/** Create a sequence file on the primary page and open it (capturing its base version). */ +async function createAndOpenSequence(): Promise { + const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seq`); + await workspace.searchForFileAndWait(sequenceName); + await workspace.clickFile(sequenceName); + await expect(workspace.saveSequenceButton).toBeVisible({ timeout: 10000 }); + return sequenceName; +} + +/** The concurrent editor opens the same file and saves a change, advancing the server version. */ +async function otherEditorSaves(sequenceName: string, content: string): Promise { + await otherWorkspace.goto(); + await otherWorkspace.searchForFileAndWait(sequenceName); + await otherWorkspace.clickFile(sequenceName); + await expect(otherWorkspace.saveSequenceButton).toBeVisible({ timeout: 10000 }); + await otherWorkspace.fillSequenceContent(content); + await otherWorkspace.saveSequence(); +} + +/** Edit on the primary page and click Save, expecting the concurrency check to reject it. */ +async function editAndSaveExpectingConflict(content: string): Promise { + await workspace.fillSequenceContent(content); + await expect(workspace.saveSequenceButton).toBeEnabled(); + await workspace.saveSequenceButton.click(); +} + +test.describe.serial('Workspace simultaneous-edit protection', () => { + test('consecutive saves of the same file succeed (token round-trips on PUT)', async () => { + // No concurrent editor here: this confirms a save advances the stored token so the next + // save against the same editor does not spuriously conflict. + await createAndOpenSequence(); + + await workspace.fillSequenceContent('// first change'); + await workspace.saveSequence(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + + await workspace.fillSequenceContent('// second change'); + await workspace.saveSequence(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + }); + + test('a stale save shows the conflict modal with a diff', async () => { + const sequenceName = await createAndOpenSequence(); + await otherEditorSaves(sequenceName, '// their change'); + + await editAndSaveExpectingConflict('// my change'); + + await conflict.waitForConflict(); + + // Cancel leaves the document dirty (Save still enabled) and keeps the stale token. + await conflict.cancel(); + await expect(workspace.saveSequenceButton).toBeEnabled(); + + // Resolve the still-dirty document so the next test starts from a clean editor: + // saving again re-checks the (still stale) token, then take theirs to rebase clean. + await workspace.saveSequenceButton.click(); + await conflict.waitForConflict(); + await conflict.takeTheirs(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + }); + + test('"Keep theirs" rebases the editor and the next save succeeds', async () => { + const sequenceName = await createAndOpenSequence(); + await otherEditorSaves(sequenceName, '// their change'); + + await editAndSaveExpectingConflict('// my change'); + await conflict.waitForConflict(); + await conflict.takeTheirs(); + + // Editor now reflects the server version; saving a fresh edit succeeds (token advanced, + // no immediate re-conflict). + await workspace.fillSequenceContent('// after taking theirs'); + await workspace.saveSequence(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + }); + + test('"Keep theirs" is undoable — Ctrl/Cmd+Z restores the discarded edits', async () => { + const sequenceName = await createAndOpenSequence(); + await otherEditorSaves(sequenceName, '// their change'); + + await editAndSaveExpectingConflict('// my change'); + await conflict.waitForConflict(); + await conflict.takeTheirs(); + + // Right after taking theirs the editor shows the server version. + const editor = setup.page.locator('.cm-content').first(); + await expect(editor).toContainText('// their change'); + + // The rebase is recorded in the undo history, so a single undo restores the discarded edits + // and the document goes dirty again (restored content differs from the new server baseline). + await editor.click(); + await setup.page.keyboard.press('ControlOrMeta+z'); + await expect(editor).toContainText('// my change'); + await expect(workspace.saveSequenceButton).toBeEnabled(); + + // Leave a clean editor for the next serial test. "Keep theirs" already advanced the stored + // token to the server version, so this save succeeds without re-conflicting. Without it the + // dirty doc leaks: the next test's createAndOpenSequence auto-selects its new file, which pops + // a "Navigate Away" modal whose backdrop blocks the file-row click. + await workspace.saveSequence(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + }); + + test('"Keep mine" saves the local version and the next save succeeds', async () => { + const sequenceName = await createAndOpenSequence(); + await otherEditorSaves(sequenceName, '// their change'); + + await editAndSaveExpectingConflict('// my change'); + await conflict.waitForConflict(); + await conflict.takeMine(); + + // Force-overwrite saved successfully and advanced the token. + await workspace.waitForToast('Workspace File Saved Successfully'); + await expect(workspace.saveSequenceButton).toBeDisabled(); + + await workspace.fillSequenceContent('// after taking mine'); + await workspace.saveSequence(); + await expect(workspace.saveSequenceButton).toBeDisabled(); + }); + + test('Ctrl/Cmd+S is ignored while the conflict modal is open (no stacked save)', async () => { + const sequenceName = await createAndOpenSequence(); + await otherEditorSaves(sequenceName, '// their change'); + + await editAndSaveExpectingConflict('// my change'); + await conflict.waitForConflict(); + + // The global save shortcut must not fire another save / stack a second modal. + await setup.page.keyboard.press('ControlOrMeta+s'); + await expect(conflict.takeMineButton).toHaveCount(1); + await expect(conflict.conflictTitle).toBeVisible(); + + await conflict.takeTheirs(); + }); + + test('a file deleted underneath shows the deleted/moved variant — Recreate restores it', async () => { + const sequenceName = await createAndOpenSequence(); + + // Concurrent editor deletes the file out from under the open editor. + await otherWorkspace.goto(); + await otherWorkspace.searchForFileAndWait(sequenceName); + await otherWorkspace.deleteFile(sequenceName); + + await editAndSaveExpectingConflict('// my change after delete'); + await conflict.waitForDeleted(); + await conflict.recreate(); + + await workspace.waitForToast('Workspace File Saved Successfully'); + // The recreated file is back in the tree. + await workspace.searchForFileAndWait(sequenceName); + await expect(workspace.getFileRow(sequenceName)).toBeVisible(); + }); + + test('the deleted/moved variant — "Discard & close" closes the document', async () => { + const sequenceName = await createAndOpenSequence(); + + await otherWorkspace.goto(); + await otherWorkspace.searchForFileAndWait(sequenceName); + await otherWorkspace.deleteFile(sequenceName); + + await editAndSaveExpectingConflict('// my change after delete'); + await conflict.waitForDeleted(); + await conflict.discardAndClose(); + + // The unsaved edits are discarded — the editor is no longer dirty (Save disabled) and + // the discarded content is gone. + await expect(workspace.saveSequenceButton).toBeDisabled(); + await expect(setup.page.locator('.cm-content').first()).not.toContainText('my change after delete'); + }); +}); diff --git a/e2e-tests/tests/workspace.test.ts b/e2e-tests/tests/workspace.test.ts index b57a672bd6..7fa73aa031 100644 --- a/e2e-tests/tests/workspace.test.ts +++ b/e2e-tests/tests/workspace.test.ts @@ -603,17 +603,31 @@ test.describe.serial('Workspace', () => { }); test('Toggle file read-only and verify editor is locked', async () => { - // Create a sequence file to test with - const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seq`); + // Use the adaptation's input-sequence extension so the file opens as a sequence with the + // Selected Command panel (a plain `.seq` would open as generic text — no command panel). + const { sequenceName } = await workspace.createSequence(undefined, `${generateRandomName()}.seqN.txt`); await workspace.searchForFileAndWait(sequenceName); await workspace.clickFile(sequenceName); // Wait for the editor to load and the file metadata banner to appear await expect(workspace.readOnlyCheckbox).toBeVisible({ timeout: 10000 }); + // Add a command so the Selected Command panel renders editable argument inputs. + await workspace.fillSequenceContent('C FSW_CMD_0 "ON" true 0.5'); + + // The Selected Command panel (right side) shows the command's argument editors; + // float_arg_0 renders as a numeric input (spinbutton) labeled by its arg name. + const commandArgInput = setup.page.getByRole('spinbutton', { name: 'float_arg_0' }); + await expect(commandArgInput).toBeVisible({ timeout: 10000 }); + // Verify the file is initially editable — title should NOT contain "(Read only)" await expect(setup.page.getByText('(Read only)')).not.toBeVisible(); await expect(workspace.saveSequenceButton).toBeVisible(); + // ...and the command argument inputs are interactive. + await expect(commandArgInput).toBeEnabled(); + + // Persist so the document is clean before toggling read-only / deleting later. + await workspace.saveSequence(); // Toggle read-only ON await workspace.readOnlyCheckbox.click(); @@ -625,6 +639,10 @@ test.describe.serial('Workspace', () => { // Verify the Save button is hidden (read-only files can't be saved) await expect(workspace.saveSequenceButton).not.toBeVisible(); + // Verify the Selected Command panel argument inputs are disabled while read-only — + // `EditorState.readOnly` alone wouldn't stop the form-builder from editing the document. + await expect(commandArgInput).toBeDisabled(); + // Verify the editor rejects input — type something and confirm content didn't change await workspace.sequenceEditor.click(); await setup.page.keyboard.type('this should not appear'); @@ -639,6 +657,8 @@ test.describe.serial('Workspace', () => { // Verify the Save button reappears await expect(workspace.saveSequenceButton).toBeVisible(); + // ...and the command argument inputs are interactive again. + await expect(commandArgInput).toBeEnabled(); // Cleanup await workspace.searchForFileAndWait(sequenceName); diff --git a/package-lock.json b/package-lock.json index 169670a047..8c5cfd896d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/language": "^6.10.1", "@codemirror/lint": "^6.5.0", + "@codemirror/merge": "^6.12.2", "@codemirror/view": "^6.38.2", "@fontsource/jetbrains-mono": "^5.0.19", "@lezer/common": "^1.2.3", @@ -313,6 +314,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/search": { "version": "6.5.10", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.10.tgz", diff --git a/package.json b/package.json index 4f032a089d..240b5e9c77 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/language": "^6.10.1", "@codemirror/lint": "^6.5.0", + "@codemirror/merge": "^6.12.2", "@codemirror/view": "^6.38.2", "@fontsource/jetbrains-mono": "^5.0.19", "@lezer/common": "^1.2.3", diff --git a/src/components/modals/WorkspaceSaveConflictModal.svelte b/src/components/modals/WorkspaceSaveConflictModal.svelte new file mode 100644 index 0000000000..9a4209af48 --- /dev/null +++ b/src/components/modals/WorkspaceSaveConflictModal.svelte @@ -0,0 +1,216 @@ + + + + + + + {variant === 'deleted' && !loadError ? 'File deleted or moved' : 'This file was changed by someone else'} + + +
+ {#if loadError} + {fileName} was changed by user @{editedByText} + {whenText ? ` ${whenText}` : ''}, but the latest version couldn't be loaded to compare. + + {:else if variant === 'deleted'} + + {fileName} was deleted or moved{whenText ? ` ${whenText}` : ''}. Your unsaved changes are shown below. + + {:else if allowMerge} + + {fileName} · edited by @{editedByText}{whenText ? ` ${whenText}` : ''}{changedLinesText}. Edit your + version on the right — use the arrows to pull in theirs — then Save, or keep theirs to discard your changes. + + {:else} + + {fileName} · edited by @{editedByText}{whenText ? ` ${whenText}` : ''}{changedLinesText}. Choose which + version to keep. + + {/if} +
+ +
+ {#if isLoading} +
+ + Loading the latest version… +
+ {:else if loadError} +
+ + Couldn't load the latest version of this file. Your unsaved changes are preserved — retry, or cancel and + try saving again. +
+ {:else if variant === 'deleted'} + + {:else} + (changedLines = e.detail.changedLines)} + /> + {/if} +
+
+ +
+ {#if loadError} +
+ +
+ {:else if variant === 'deleted'} +
+ + +
+ {:else if !isLoading} +
+ + {#if allowMerge} + + {:else} + + {/if} +
+ {/if} +
+ +
+
+
diff --git a/src/components/sequencing/CommandPanel/CommandPanel.svelte b/src/components/sequencing/CommandPanel/CommandPanel.svelte index c777a1f5e2..948f656c05 100644 --- a/src/components/sequencing/CommandPanel/CommandPanel.svelte +++ b/src/components/sequencing/CommandPanel/CommandPanel.svelte @@ -17,6 +17,7 @@ export let phoenixContext: PhoenixContext; export let commandInfoMapper: CommandInfoMapper; export let editorSequenceView: EditorView; + export let readOnly: boolean = false; enum CommandPanelTabs { COMMAND = 'command', @@ -126,6 +127,7 @@ {commandDictionary} {commandInfoMapper} {editorSequenceView} + {readOnly} {timeTagNode} on:selectCommandDefinition={onSelectCommandDefinition} /> diff --git a/src/components/sequencing/CommandPanel/SelectedCommand.svelte b/src/components/sequencing/CommandPanel/SelectedCommand.svelte index 822efa62cc..81968db46b 100644 --- a/src/components/sequencing/CommandPanel/SelectedCommand.svelte +++ b/src/components/sequencing/CommandPanel/SelectedCommand.svelte @@ -10,9 +10,9 @@ HwCommand, } from '@nasa-jpl/aerie-ampcs'; import type { ArgTextDef, CommandInfoMapper, TimeTagInfo } from '@nasa-jpl/aerie-sequence-languages'; - import { ArrowUpRight } from 'lucide-svelte'; import type { EditorView } from 'codemirror'; import { debounce } from 'lodash-es'; + import { ArrowUpRight } from 'lucide-svelte'; import { createEventDispatcher } from 'svelte'; import { addDefaultArgs, getMissingArgDefs } from '../../../utilities/sequence-editor/sequence-utils'; import { tooltip } from '../../../utilities/tooltip'; @@ -28,6 +28,7 @@ export let commandNameNode: SyntaxNode | null = null; export let commandNode: SyntaxNode | null = null; export let editorSequenceView: EditorView; + export let readOnly: boolean = false; export let timeTagNode: TimeTagInfo = null; export let variablesInScope: string[] = []; @@ -117,65 +118,68 @@ {/if} {#if !!commandNode} - {#if commandInfoMapper.nodeTypeHasArguments(commandNode)} - {#if !!commandDef} - {#each editorArgInfoArray as argInfo} - setInEditor(editorSequenceView, token, val), 250)} - addDefaultArgs={(commandNodeToAddArgs, missingArgDefs) => - addDefaultArgs( - commandDictionary, - editorSequenceView, - commandNodeToAddArgs, - missingArgDefs, - commandInfoMapper, - )} - /> - {/each} - - {#if missingArgDefArray.length} -
- { - if (commandNode) { - addDefaultArgs( - commandDictionary, - editorSequenceView, - commandNode, - missingArgDefArray, - commandInfoMapper, - ); - } - }} +
+ {#if commandInfoMapper.nodeTypeHasArguments(commandNode)} + {#if !!commandDef} + {#each editorArgInfoArray as argInfo} + setInEditor(editorSequenceView, token, val), 250)} + addDefaultArgs={(commandNodeToAddArgs, missingArgDefs) => + addDefaultArgs( + commandDictionary, + editorSequenceView, + commandNodeToAddArgs, + missingArgDefs, + commandInfoMapper, + )} /> + {/each} + + {#if missingArgDefArray.length} +
+ { + if (commandNode) { + addDefaultArgs( + commandDictionary, + editorSequenceView, + commandNode, + missingArgDefArray, + commandInfoMapper, + ); + } + }} + /> +
+ {/if} + {:else} +
+
{commandName ?? ''}
+
Command type is not present in dictionary
{/if} {:else}
-
{commandName ?? ''}
+
{`${formatTypeName(commandNode.name)} Name`}
+
+ { + if (commandNameNode) { + setInEditor(editorSequenceView, commandNameNode, val); + } + }} + /> +
-
Command type is not present in dictionary
{/if} - {:else} -
-
{`${formatTypeName(commandNode.name)} Name`}
-
- { - if (commandNameNode) { - setInEditor(editorSequenceView, commandNameNode, val); - } - }} - /> -
-
- {/if} +
{:else}
Select a command or open the diff --git a/src/components/sequencing/SequenceEditor.svelte b/src/components/sequencing/SequenceEditor.svelte index a55d5b515b..f72949e9e8 100644 --- a/src/components/sequencing/SequenceEditor.svelte +++ b/src/components/sequencing/SequenceEditor.svelte @@ -22,6 +22,7 @@ import type { LintDiagnostic } from '../../types/errors'; import type { WorkspaceFileMetadata } from '../../types/workspace-tree-view'; import { getLintDiagnostics } from '../../utilities/codemirror/lint'; + import { readOnlyChangeGuard } from '../../utilities/codemirror/readOnly'; import { blockTheme } from '../../utilities/codemirror/themes/block'; import { phoenixResources } from '../../utilities/sequence-editor/adaptation-resources'; import { showFailureToast, showSuccessToast } from '../../utilities/toast'; @@ -160,6 +161,9 @@ effects: compartmentReadonly.reconfigure([ EditorState.readOnly.of(!isEditable), EditorView.editable.of(isEditable), + // Block programmatic edits (lint fixes, command panel, sanitizer) that readOnly + // misses, but allow the editor's own 'file.open' content sync. + ...(isEditable ? [] : [readOnlyChangeGuard(['file.open'])]), ]), }); } @@ -347,6 +351,20 @@ return true; } + /** + * Replace the editor content and DO record it in the undo history — unlike the file-open sync, + * which is excluded from history. Used by "take theirs" so the user can Cmd-Z back to the edits + * they discarded (the full-doc replace and its inverse are exact, so undo/redo stay clean). + */ + export function rebaseContent(content: string): void { + if (editorSequenceView && editorSequenceView.state.doc.toString() !== content) { + editorSequenceView.dispatch({ + changes: { from: 0, insert: content, to: editorSequenceView.state.doc.length }, + userEvent: 'file.rebase', + }); + } + } + // Exported function to allow parent to navigate to a specific line/column export function gotoLine(line: number, column: number = 0): void { if (editorSequenceView) { @@ -385,7 +403,11 @@ EditorView.updateListener.of(viewUpdate => dispatchLintChange(viewUpdate.view)), blockTheme, compartmentAdaptation.of(inputEditorExtension), - compartmentReadonly.of([EditorState.readOnly.of(readOnly || previewOnly || isLoading)]), + compartmentReadonly.of( + readOnly || previewOnly || isLoading + ? [EditorState.readOnly.of(true), readOnlyChangeGuard(['file.open'])] + : [EditorState.readOnly.of(false)], + ), EditorView.updateListener.of(viewUpdate => { for (const tr of viewUpdate.transactions) { if (tr.annotation(Transaction.userEvent) === 'sanitize.smartQuotes') { @@ -496,7 +518,12 @@ {#if showCommandFormBuilder} {#if phoenixContext && phoenixContext.commandDictionary !== null} - + {:else} diff --git a/src/components/sequencing/form/ArgEditor.svelte b/src/components/sequencing/form/ArgEditor.svelte index 933763673b..efea7f217a 100644 --- a/src/components/sequencing/form/ArgEditor.svelte +++ b/src/components/sequencing/form/ArgEditor.svelte @@ -28,6 +28,7 @@ export let setInEditor: (token: SyntaxNode, val: string) => void; export let addDefaultArgs: (commandNode: SyntaxNode, argDefs: FswCommandArgument[]) => void; export let commandInfoMapper: CommandInfoMapper; + export let disabled: boolean = false; export let variablesInScope: string[]; let argDef: FswCommandArgument | undefined = undefined; @@ -98,6 +99,7 @@
Reference
{ if (argInfo.node) { @@ -110,6 +112,7 @@ { if (argInfo.node) { diff --git a/src/components/sequencing/form/BooleanEditor.svelte b/src/components/sequencing/form/BooleanEditor.svelte index 3b4d02f675..902c444f72 100644 --- a/src/components/sequencing/form/BooleanEditor.svelte +++ b/src/components/sequencing/form/BooleanEditor.svelte @@ -15,7 +15,7 @@
- {#each ['false', 'true'] as ev} {/each} diff --git a/src/components/sequencing/form/EnumEditor.svelte b/src/components/sequencing/form/EnumEditor.svelte index 6a5f536f1b..77066137fe 100644 --- a/src/components/sequencing/form/EnumEditor.svelte +++ b/src/components/sequencing/form/EnumEditor.svelte @@ -9,6 +9,7 @@ export let argDef: FswCommandArgumentEnum; export let commandDictionary: CommandDictionary | null = null; + export let disabled: boolean = false; export let initVal: string; export let setInEditor: (val: string) => void; @@ -37,14 +38,16 @@
{#if enumValues.length > SEARCH_THRESHOLD} {:else} - {#if !isValueInEnum} {/if} diff --git a/src/components/sequencing/form/NumEditor.svelte b/src/components/sequencing/form/NumEditor.svelte index 33d9ca07bf..6ab1ca7503 100644 --- a/src/components/sequencing/form/NumEditor.svelte +++ b/src/components/sequencing/form/NumEditor.svelte @@ -31,7 +31,7 @@
- + {#if typeof min === 'number' && typeof max === 'number' && (valFloat < min || valFloat > max)}