diff --git a/docs/features/shelves-and-switching.md b/docs/features/shelves-and-switching.md index 43cda0f4e..6161b91db 100644 --- a/docs/features/shelves-and-switching.md +++ b/docs/features/shelves-and-switching.md @@ -19,9 +19,10 @@ with a way to reveal it: `filesKeptAside`, `noteKeptAside`), switches, and merge `applyShelveCleanly`). Stopping is honoured only until the changes are shelved. Failures put the changes back (`rollBackSwitch`), switching back first if the switch moved the workspace halfway; when that isn't possible the error says which shelve holds them and where to restore them. Every flow that shelves changes out of the workspace (switch, -shelve away, update) puts them back the same way (`putBackAfterFailure`). Left shelves (the app's and the official -client's) are offered again by the "Welcome back" banner in Changes (`features/leftChanges`), or restored -automatically on arrival when they apply cleanly (`restoreOnArrival`). Changes still waiting to be brought (conflicts +shelve away, update, loading an xlink's files: docs/features/xlinks.md) puts them back the same way +(`putBackAfterFailure`). Left shelves (the app's and the official client's) are offered again by the "Welcome back" +banner in Changes (`features/leftChanges`), or restored automatically on arrival when they apply cleanly +(`restoreOnArrival`). Changes still waiting to be brought (conflicts left for the merge view) are offered on the target, and as left ones on the source if the user goes back instead. Both places that ask what happens to the pending changes, the switch's question (`SwitchWithChangesDialog`, diff --git a/docs/features/xlinks.md b/docs/features/xlinks.md index 15a9894f1..89807ea36 100644 --- a/docs/features/xlinks.md +++ b/docs/features/xlinks.md @@ -44,9 +44,44 @@ it saves are decided by `xlinkDraft.ts` (`xlinkDialogFields`, `newXlinkOf`, `xli server; nothing changes those or the repository, so the edit dialog shows them in its description only. Moving or renaming the folder is Rename, Cut and Paste, as for any item. -Creating an xlink makes it a pending change (added) and editing one checks it out; editing one not checked in yet -leaves it added (the dialog says which: `descriptionOfEdited`). The change reaches others once checked in. A new -xlink's folder stays empty until then, and fills when the workspace updates. +Editing an xlink checks it out; editing one not checked in yet leaves it added (the dialog says which: +`descriptionOfEdited`). The change reaches others once checked in. + +## Creating checks it in and loads its files + +`cm` fills an xlink's folder only from the server's tree: a pending xlink has no files, and no `cm` command loads +them. So **Create and check in** (`XlinksApi.create`) creates the xlink, checks it in alone with the dialog's comment +(`checkinCommentOf`: the one typed, or "Add xlink "), and loads its files, one operation. Other pending changes +stay pending. Updating the workspace unnamed may not load them: it is at the changeset the checkin made, so +`cm update` can find it up to date. Updating the folder holding the xlink does (the workspace's root, named, for one at +the root: `loadXlinkArgs`); its own folder doesn't, as `cm` fails there for an xlink showing a folder of its +repository ("Can't move to ") and removes the folder. A partial update brings nothing else in: `cm` refuses +one while the workspace is out of date. + +Before updating a folder, `cm` looks for pending changes in the whole workspace and does as client.conf's +`PendingChangesOnSwitchAction` says. Unset, as by default, it updates the folder and leaves them as they are. Set to +shelve them, it would shelve, undo and merge them back itself, which the app never lets `cm` do (as +`switchWithChanges`); `--machinereadable` turns that into refusing, as a setting to fail does. On a refusal +(`loadXlink`), unchanged checkouts are undone, a merge in progress is left alone (the files don't load), and other +changes are put aside as a switch puts them: an automatic shelve recorded with `reason: 'xlink'`, undo, added files +moved aside, the folder updated, the shelve merged back. The workspace didn't move, so only a change under the new +folder could conflict; changes that don't come back cleanly wait in their shelve, offered by "Welcome back" as "put +aside to load an xlink". A failure before the files load puts the changes back. The refusal's words are localized, +so it is told apart only by changes being pending: any failed update with changes pending takes this path. When `cm` +fails before the changes are shelved (the server is down), nothing changed and the error is what `cm update` said; +the app's own reasons they can't be shelved (changes inside another xlink: "Check them in first") stay as they are. + +The files load with no progress bar, "Loading its files" alone: `--forcedetailedprogress`, which prints the bar +`readUpdateProgress` reads, is turned off by `--machinereadable`, which keeps `cm` from shelving. + +Once `cm xlink` made it, the xlink exists whatever comes next: a later step that fails ends the operation with what +it is now (`CreatedXlink`: not checked in, a pending change to check in from Changes; or checked in without its +files) rather than an error, so the dialog closes instead of offering to create it again (`createdXlinkMessage`). +Only `cm xlink` failing keeps the dialog open. + +**Load its files** (`XlinksApi.load`, `loadXlinkFiles`) loads them later, the same way: on a checked-in xlink whose +folder Files listed empty (`unloadedXlinkOf`), as one checked in from Changes is, and from the toast when they didn't +load as it was created. An added xlink isn't offered it: nothing loads one before it's checked in. An edit that fails halfway leaves the xlink as the dialog still shows it, so Save can be pressed again: when adding the new rules fails, the rules just deleted are added back (`xlinksService`). Pointing it again where the first try @@ -54,14 +89,16 @@ already did changes nothing. ## `cm` commands -All in `main/cm/xlinkArgs.ts` (arguments) and `xlinkShow.ts` (`parseXlinkRules`), run by `xlinksService`. None -moves files, so each is a quick `query()`; each creates or edits an item, so it counts as the app's own write -(`changesWorkspace`), except `--show`. +All in `main/cm/xlinkArgs.ts` (arguments) and `xlinkShow.ts` (`parseXlinkRules`), run by `xlinksService`. No +`cm xlink` moves files, so each is a quick `query()`; each creates or edits an item, so it counts as the app's own +write (`changesWorkspace`), except `--show`. Creating also checks in and updates, both processes (`execute()`). | Does | Command | When | | ------------------------ | ------------------------------------------------------------------------ | ------------------------------------------- | | Reads the rules | `cm xlink --show ` | Edit xlink… on a writable xlink, once | -| Creates | `cm xlink [-w] [-rs] [rules]` | Create xlink | +| Creates | `cm xlink [-w] [-rs] [rules]` | Create and check in | +| Checks the new one in | `cm checkin --all --dependencies -commentsfile=` (`checkinArgs`) | Create and check in, once created | +| Loads its files | `cm update --machinereadable --noinput --dontmerge` | Create and check in, once checked in; Load its files | | Points elsewhere | `cm xlink -e ` | Save, when the point changed | | Deletes, then adds rules | `cm xlink --deleterules `, then `--addrules` | Save, when rules changed (one each at most) | diff --git a/src/main/cm/xlinkArgs.test.ts b/src/main/cm/xlinkArgs.test.ts index ab964b054..bb1296db0 100644 --- a/src/main/cm/xlinkArgs.test.ts +++ b/src/main/cm/xlinkArgs.test.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import type { NewXlink } from '@shared/domain/xlinks'; import { repositoryFolder } from '@shared/domain/xlinks'; -import { createXlinkArgs, labelChangesetArgs, repositoryFolderArgs, xlinkPointSpec, xlinkRuleArg, xlinkTreeSpec } from './xlinkArgs'; +import { createXlinkArgs, loadXlinkArgs, labelChangesetArgs, repositoryFolderArgs, xlinkPointSpec, xlinkRuleArg, xlinkTreeSpec } from './xlinkArgs'; import { TREE_ITEM_FORMAT } from './treeItems'; const LIB = join('wk', 'code', 'lib'); @@ -78,3 +78,16 @@ describe('The tree of the repository an xlink points to', () => { expect(repositoryFolderArgs('/', 'cs:4@lib@local')[1]).toBe('/'); }); }); + +describe('Loading a checked-in xlink’s files', () => { + const WORKSPACE = join('wkspaces', 'game'); + + // Updating the xlink's own folder fails for one showing a folder of its repository ("Can't move to "). + it('updates the folder holding the xlink, never its own', () => { + expect(loadXlinkArgs(WORKSPACE, 'code/lib')).toEqual(['update', join(WORKSPACE, 'code'), '--machinereadable', '--noinput', '--dontmerge']); + }); + + it('updates the workspace’s root, named, for an xlink at the root', () => { + expect(loadXlinkArgs(WORKSPACE, 'lib')[1]).toBe(join(WORKSPACE)); + }); +}); diff --git a/src/main/cm/xlinkArgs.ts b/src/main/cm/xlinkArgs.ts index ce73bdad6..b164a96ba 100644 --- a/src/main/cm/xlinkArgs.ts +++ b/src/main/cm/xlinkArgs.ts @@ -1,11 +1,13 @@ // `cm xlink` arguments. The command creates an xlink as a pending change (`cm xlink [-w] [-rs] // [rules]`), edits one, checking it out (`-e `: only where it points; writable, folder and server stay), // and adds or deletes its branch expansion rules (`-ar`/`-dr `). None of it reads or writes the -// xlinked files: they come with the next update, so each is a quick command. +// xlinked files, so each is a quick command: they come once the xlink is checked in and its folder updated +// (`loadXlinkArgs`). import { fullBranchName, repositoryFolder, type NewXlink, type XlinkPoint, type XlinkRule } from '@shared/domain/xlinks'; import { equalsCondition, findArgs } from './findQuery'; import { FOUND_LABELS } from './foundObjects'; +import { toAbsolutePath } from '../files/workspacePaths'; import { TREE_ITEM_FORMAT } from './treeItems'; /** Where an xlink points, as `cm xlink` takes it: a changeset, branch or label spec in its repository (`name@server`). */ @@ -48,6 +50,22 @@ export function deleteXlinkRulesArgs(absolutePath: string, rules: readonly Xlink return ['xlink', '--deleterules', absolutePath, ...rules.map(xlinkRuleArg)]; } +/** + * `cm update` of the folder holding a checked-in xlink (the workspace's root, named, for one at the root), which loads + * its files. Not the xlink's own folder: for an xlink showing a folder of its repository, `cm` fails there ("Can't move + * to ") and removes the folder. Nor the workspace unnamed: it is at the changeset that added the xlink already, + * and `cm update` may find it up to date. Updating a folder brings nothing else in: `cm` refuses a partial update while + * the workspace is out of date ("Cannot perform a partial update when the workspace is out of date"). Updating a folder, `cm` + * looks for pending changes in the whole workspace first, and does as client.conf's PendingChangesOnSwitchAction says: + * with none set, as by default, it updates the folder and leaves them as they are; set to shelve them, it would shelve, + * undo and merge them back itself, which `--machinereadable` turns into refusing (as a setting to fail does), so the + * app moves them aside itself (`loadXlink`). `--noinput`: never ask; `--dontmerge`: never open a merge tool. + */ +export function loadXlinkArgs(workspacePath: string, xlinkPath: string, platform: NodeJS.Platform = process.platform): string[] { + const holdingFolder = xlinkPath.split('/').slice(0, -1).join('/'); + return ['update', toAbsolutePath(workspacePath, holdingFolder, platform), '--machinereadable', '--noinput', '--dontmerge']; +} + export function showXlinkArgs(absolutePath: string): string[] { return ['xlink', '--show', absolutePath]; } diff --git a/src/main/services/createServices.ts b/src/main/services/createServices.ts index 78ceae263..720d296c6 100644 --- a/src/main/services/createServices.ts +++ b/src/main/services/createServices.ts @@ -83,7 +83,7 @@ export function createServices(context: ServiceContext): UvcsApi { updates: createUpdatesService(context), windows: createWindowsService(context), workspaces: createWorkspacesService(context, switching), - xlinks: createXlinksService(context), + xlinks: createXlinksService(context, switching), }; } diff --git a/src/main/services/xlinksService.test.ts b/src/main/services/xlinksService.test.ts index b1e256606..df182f578 100644 --- a/src/main/services/xlinksService.test.ts +++ b/src/main/services/xlinksService.test.ts @@ -1,18 +1,28 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import type { NewXlink } from '@shared/domain/xlinks'; +import { describe, expect, it, vi } from 'vitest'; import { FOUND_LABELS } from '../cm/foundObjects'; import { recordsOutput } from '../cm/testing/cmOutput'; import { cmFails, fakeCmClient, type CmAnswer } from '../cm/testing/fakeCmClient'; import { TREE_ITEMS } from '../cm/treeItems'; +import { playAlongDependencies } from '../workspace/testing/playAlongDependencies'; +import { playAlongWorkspace } from '../workspace/testing/playAlongWorkspace'; +import type { SwitchContext } from './ServiceContext'; +import { readingFileOption } from './testing/readingFileOption'; +import { serviceContext } from './testing/serviceContext'; import { createXlinksService } from './xlinksService'; +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })); + const WORKSPACE = join(tmpdir(), 'wkspaces', 'game'); const LIB = join(WORKSPACE, 'code', 'lib'); function xlinks(answers: Record) { const fake = fakeCmClient(answers); - return { ...fake, service: createXlinksService({ cm: fake.cm }) }; + return { ...fake, service: createXlinksService(serviceContext(fake.cm), {} as SwitchContext) }; } describe('Reading an xlink’s rules', () => { @@ -29,28 +39,88 @@ describe('Reading an xlink’s rules', () => { }); describe('Creating an xlink', () => { - it('runs one quick cm xlink at its folder in the workspace', async () => { - const { service, commands } = xlinks({ xlink: '' }); - - await service.create(WORKSPACE, { - path: 'code/lib', - repository: 'lib@local', - point: { kind: 'branch', branch: '/main' }, - writable: true, - relativeServer: false, - folder: '/', - rules: [{ from: '/main/task', to: '/main/dev' }], - }); - - expect(commands).toMatchObject([{ via: 'query', args: ['xlink', '-w', LIB, '/', 'br:/main@lib@local', 'br:/main/task-br:/main/dev'] }]); + const ENGINE: NewXlink = { + path: 'code/lib', + repository: 'lib@local', + point: { kind: 'branch', branch: '/main' }, + writable: true, + relativeServer: false, + folder: '/', + rules: [{ from: '/main/task', to: '/main/dev' }], + }; + const CHECKED_IN = `CI_START\nAD ${LIB}\nCHANGESET cs:3@br:/main@game@local (mount:'/')\n`; + + it('creates it, checks in it alone with the comment, and loads its files with an update of its folder', async () => { + const { seen, answer } = readingFileOption('-commentsfile=', CHECKED_IN); + const { service, commands } = xlinks({ xlink: '', checkin: answer, update: '' }); + + expect(await service.create(WORKSPACE, ENGINE, 'Add the engine', 'op-1')).toEqual({ kind: 'loaded', changesetId: 3 }); + + expect(commands).toMatchObject([ + { via: 'query', args: ['xlink', '-w', LIB, '/', 'br:/main@lib@local', 'br:/main/task-br:/main/dev'] }, + { via: 'execute', args: ['checkin', LIB, '--all', '--dependencies', `-commentsfile=${seen.file}`, '--machinereadable', '--symlink'], options: { cwd: WORKSPACE } }, + { via: 'execute', args: ['update', join(WORKSPACE, 'code'), '--machinereadable', '--noinput', '--dontmerge'], options: { cwd: WORKSPACE } }, + ]); + expect(seen.content).toBe('Add the engine'); + expect(existsSync(seen.file)).toBe(false); }); it('fails with cm’s words, as when the folder holding it isn’t controlled', async () => { - const { service } = xlinks({ xlink: cmFails(`${join(WORKSPACE, 'code')} is not in a workspace.`) }); + const { service, commands } = xlinks({ xlink: cmFails(`${join(WORKSPACE, 'code')} is not in a workspace.`) }); - const created = service.create(WORKSPACE, { path: 'code/lib', repository: 'lib@local', point: { kind: 'changeset', changeset: 1 }, writable: false, relativeServer: false, folder: '/', rules: [] }); + const created = service.create(WORKSPACE, { ...ENGINE, writable: false, rules: [] }, 'c', 'op-1'); await expect(created).rejects.toThrow('is not in a workspace'); + expect(commands).toHaveLength(1); + }); + + it('keeps the xlink as a pending change when it can’t be checked in, and says why', async () => { + const { service, ran } = xlinks({ xlink: '', checkin: cmFails('The server is unreachable.') }); + + expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notCheckedIn', reason: 'The server is unreachable' }); + expect(ran('update')).toBe(false); + }); + + it('says its files didn’t load once it is checked in, and why', async () => { + const { service } = xlinks({ xlink: '', checkin: CHECKED_IN, update: cmFails('The server is unreachable.'), status: '' }); + + expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notLoaded', changesetId: 3, reason: 'The server is unreachable' }); + }); + + it('says so in plain words when cm checks in nothing', async () => { + const { service } = xlinks({ xlink: '', checkin: 'CI_START\nNO_CHANGES_APPLIED\n' }); + + expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notCheckedIn', reason: 'Nothing was checked in' }); + }); + + it('names the shelve the changes put aside to load its files wait in, when they don’t come back on their own', async () => { + const workspacePath = join(await mkdtemp(join(tmpdir(), 'uvcs-xlink-create-')), 'wk'); + const workspace = playAlongWorkspace(workspacePath, { pending: { 'src/a.txt': 'CH' }, updateRefusesPendingChanges: true, fail: { shelveConflicts: 1 } }); + const { deps } = playAlongDependencies(workspace.cm, workspacePath); + // The play-along workspace knows no xlinks: creating and checking in this one answer as cm does. + const play = { query: workspace.cm.query.bind(workspace.cm), execute: workspace.cm.execute.bind(workspace.cm) }; + workspace.cm.query = (args, options) => (args[0] === 'xlink' ? Promise.resolve('') : play.query(args, options)); + workspace.cm.execute = (args, options) => (args[0] === 'checkin' ? Promise.resolve(CHECKED_IN) : play.execute(args, options)); + const service = createXlinksService(serviceContext(workspace.cm), { switchShelves: deps.records, leftChanges: deps.leftChanges }); + + expect(await service.create(workspacePath, ENGINE, 'c', 'op-1')).toEqual({ kind: 'loaded', changesetId: 3, changesWaitIn: 7 }); + expect(workspace.updatedFolders).toEqual(['code']); + }); + +}); + +describe('Loading a checked-in xlink’s files', () => { + it('updates its folder alone, as an operation', async () => { + const { service, commands } = xlinks({ update: '' }); + + expect(await service.load(WORKSPACE, 'code/lib', 'op-1')).toEqual({}); + expect(commands).toMatchObject([{ via: 'execute', args: ['update', join(WORKSPACE, 'code'), '--machinereadable', '--noinput', '--dontmerge'] }]); + }); + + it('fails with what went wrong: nothing exists to tell apart from it', async () => { + const { service } = xlinks({ update: cmFails('The server is unreachable.'), status: '' }); + + await expect(service.load(WORKSPACE, 'code/lib', 'op-1')).rejects.toThrow('The server is unreachable.'); }); }); diff --git a/src/main/services/xlinksService.ts b/src/main/services/xlinksService.ts index 9f085a264..3c3574d54 100644 --- a/src/main/services/xlinksService.ts +++ b/src/main/services/xlinksService.ts @@ -1,5 +1,9 @@ +import { join } from 'node:path'; +import { app } from 'electron'; import type { XlinksApi } from '@shared/api/xlinks'; -import type { NewXlink, XlinkEdit, XlinkPoint } from '@shared/domain/xlinks'; +import type { CreatedXlink, LoadedXlinkFiles, NewXlink, XlinkEdit, XlinkPoint } from '@shared/domain/xlinks'; +import { checkinArgs } from '../cm/checkinArgs'; +import { readCheckinOutput } from '../cm/checkinOutput'; import { layoutReader } from '../cm/formatRecords'; import { FOUND_LABELS } from '../cm/foundObjects'; import { subfoldersIn } from '../cm/repositoryFolders'; @@ -16,20 +20,62 @@ import { xlinkTreeSpec, } from '../cm/xlinkArgs'; import { parseXlinkRules } from '../cm/xlinkShow'; +import { withTempFile } from '../files/tempFile'; import { toAbsolutePath } from '../files/workspacePaths'; -import type { ServiceContext } from './ServiceContext'; +import { failureReason } from '../workspace/putShelvedChangesBack'; +import { loadXlink, type XlinkLoad } from '../workspace/loadXlink'; +import type { ServiceContext, SwitchContext } from './ServiceContext'; -/** Xlinks created and edited: every command a quick one, as none moves files (`xlinkArgs`). */ -export function createXlinksService({ cm }: Pick): XlinksApi { +const filesOf = (load: XlinkLoad): LoadedXlinkFiles => (load.kind === 'changesWaiting' ? { changesWaitIn: load.shelveId } : {}); + +/** + * Xlinks created and edited. Creating one checks it in and loads its files, an operation; every other command is a + * quick one, as none moves files (`xlinkArgs`). + */ +export function createXlinksService({ cm, operations }: Pick, { switchShelves, leftChanges }: SwitchContext): XlinksApi { const inWorkspace = (workspacePath: string) => ({ cwd: workspacePath }); + const shelveFlow = { cm, records: switchShelves, leftChanges, backupsRoot: join(app.getPath('userData'), 'shelve-backups') }; async function rules(workspacePath: string, path: string) { const output = await cm.query(showXlinkArgs(toAbsolutePath(workspacePath, path)), inWorkspace(workspacePath)); return parseXlinkRules(output); } - async function create(workspacePath: string, xlink: NewXlink) { - await cm.query(createXlinkArgs(toAbsolutePath(workspacePath, xlink.path), xlink), inWorkspace(workspacePath)); + // Once `cm xlink` made it, the xlink exists whatever comes next: a later failure is what it resolves to, not an error, + // so the dialog doesn't offer to create it again. + function create(workspacePath: string, xlink: NewXlink, comment: string, operationId: string): Promise { + return operations.run(operationId, async (context) => { + const absolutePath = toAbsolutePath(workspacePath, xlink.path); + await cm.query(createXlinkArgs(absolutePath, xlink), inWorkspace(workspacePath)); + + context.reportProgress('Checking in the xlink'); + let changesetId: number; + try { + changesetId = await checkInAlone(workspacePath, absolutePath, comment); + } catch (error) { + return { kind: 'notCheckedIn', reason: failureReason(error) }; + } + + context.reportProgress('Loading its files'); + try { + return { kind: 'loaded', changesetId, ...filesOf(await loadXlink(shelveFlow, workspacePath, xlink.path, context)) }; + } catch (error) { + return { kind: 'notLoaded', changesetId, reason: failureReason(error) }; + } + }); + } + + function load(workspacePath: string, path: string, operationId: string): Promise { + return operations.run(operationId, async (context) => filesOf(await loadXlink(shelveFlow, workspacePath, path, context))); + } + + /** Checks in the xlink and nothing else: the folder holding it, and the added folders above, go in with it (`checkinArgs`). */ + function checkInAlone(workspacePath: string, absolutePath: string, comment: string): Promise { + return withTempFile(comment, async (commentsFile) => { + const checkin = readCheckinOutput(await cm.execute(checkinArgs([absolutePath], commentsFile, false), inWorkspace(workspacePath))); + if (checkin.kind === 'noChanges') throw new Error('Nothing was checked in'); + return checkin.changesetId; + }); } async function treePoint(workspacePath: string, repository: string, point: XlinkPoint) { @@ -63,5 +109,5 @@ export function createXlinksService({ cm }: Pick): XlinksA } } - return { rules, create, treePoint, folders, edit }; + return { rules, create, load, treePoint, folders, edit }; } diff --git a/src/main/workspace/loadXlink.test.ts b/src/main/workspace/loadXlink.test.ts new file mode 100644 index 000000000..ec6e9eb80 --- /dev/null +++ b/src/main/workspace/loadXlink.test.ts @@ -0,0 +1,129 @@ +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AUTOMATIC_SHELVE_COMMENT } from '@shared/domain/shelve'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { recordingContext } from '../operations/testing/recordingContext'; +import { CmError } from '../cm/CmError'; +import { loadXlink } from './loadXlink'; +import { playAlongDependencies } from './testing/playAlongDependencies'; +import { playAlongWorkspace, type WorkspaceScenario } from './testing/playAlongWorkspace'; + +const EDITED_AND_ADDED = { 'src/a.txt': 'CH', 'src/new.txt': 'AD' }; +const FAILED = { args: [], exitCode: 1, logEntryId: 0 } as unknown as ConstructorParameters[1]; + +let workspacePath: string; + +beforeEach(async () => { + workspacePath = join(await mkdtemp(join(tmpdir(), 'uvcs-load-xlink-')), 'wk'); + await mkdir(join(workspacePath, 'src'), { recursive: true }); + await writeFile(join(workspacePath, 'src', 'new.txt'), 'added\n'); +}); + +function workspaceWith(scenario: WorkspaceScenario) { + const workspace = playAlongWorkspace(workspacePath, scenario); + return { ...workspace, ...playAlongDependencies(workspace.cm, workspacePath) }; +} + +const load = (deps: ReturnType['deps']) => loadXlink(deps, workspacePath, 'code/lib', recordingContext().context); + +describe('Loading a checked-in xlink’s files', () => { + it('updates its folder alone, leaving the other pending changes as they are', async () => { + const { deps, commands, pending, updatedFolders, shelves } = workspaceWith({ pending: EDITED_AND_ADDED }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + + expect(updatedFolders).toEqual(['code']); + expect(commands).toMatchObject([ + // `--machinereadable`: a cm set to shelve the changes on update refuses instead, and never shelves them itself. + { via: 'execute', args: ['update', join(workspacePath, 'code'), '--machinereadable', '--noinput', '--dontmerge'], options: { cwd: workspacePath } }, + ]); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(shelves()).toEqual([]); + }); + + it('puts the changes aside when cm refuses because of them, loads the files, and merges them back', async () => { + const { deps, pending, updatedFolders, shelves, deletedShelves, recordOf, lines, commands } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + const readsBeforeShelving = commands.slice(0, commands.findIndex((command) => command.args[0] === 'shelveset')); + // The changes in the way are read once: the shelve takes the same snapshot. + expect(readsBeforeShelving.filter((command) => command.args.includes('--changelists'))).toHaveLength(1); + + expect(updatedFolders).toEqual(['code']); + expect(lines()).toContain(`undo -r ${workspacePath} --symlink`); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(existsSync(join(workspacePath, 'src', 'new.txt'))).toBe(true); + expect(shelves()).toEqual([]); + expect(deletedShelves).toEqual([7]); + expect(recordOf(7)).toBeUndefined(); + }); + + it('shelves the changes put aside as the official client leaves them, so they are found should they not come back', async () => { + const { deps, shelveComment, leftChanges, recordOf } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true, fail: { shelveConflicts: 1 } }); + + expect(await load(deps)).toEqual({ kind: 'changesWaiting', shelveId: 7 }); + + expect(shelveComment(7)).toMatch(new RegExp(`^${AUTOMATIC_SHELVE_COMMENT}`)); + expect(recordOf(7)).toMatchObject({ mode: 'leave', reason: 'xlink' }); + expect(await leftChanges.find(workspacePath)).toMatchObject([{ shelveId: 7, reason: 'xlink' }]); + }); + + it('undoes checkouts holding no change instead of shelving them', async () => { + const { deps, pending, updatedFolders, shelves, lines } = workspaceWith({ pending: { 'src/a.txt': 'CO' }, updateRefusesPendingChanges: true }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + + expect(lines()).toContain(`undo --unchanged -r ${workspacePath} --symlink`); + expect(pending()).toEqual({}); + expect(shelves()).toEqual([]); + expect(updatedFolders).toEqual(['code']); + }); + + it('leaves a merge in progress alone, as no shelve can hold it', async () => { + const { deps, pending, shelves } = workspaceWith({ pending: { 'src/a.txt': 'CH' }, mergingFrom: 3, updateRefusesPendingChanges: true }); + + await expect(load(deps)).rejects.toThrow('A merge in progress keeps its files from loading'); + expect(pending()).toEqual({ 'src/a.txt': 'CH' }); + expect(shelves()).toEqual([]); + }); + + it('fails with cm’s words when nothing is pending: no change could be in the way', async () => { + const { deps, shelves } = workspaceWith({ fail: { update: 'The server is unreachable.' } }); + + await expect(load(deps)).rejects.toThrow('The server is unreachable.'); + expect(shelves()).toEqual([]); + }); + + it('tells what cm update said when the changes in the way can’t even be shelved', async () => { + const { deps, pending, lines } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true }); + const execute = deps.cm.execute; + deps.cm.execute = async (args, options) => (args[0] === 'shelveset' ? Promise.reject(new CmError('The server is unreachable.', FAILED)) : execute(args, options)); + + await expect(load(deps)).rejects.toThrow('The workspace cannot be updated since there are pending changes.'); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(lines().some((line) => line.startsWith('undo'))).toBe(false); + }); + + it('says what to do when the changes in the way can’t be shelved, leaving them as they were', async () => { + const { deps, pending, lines } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true, fail: { shelveMisses: 'src/a.txt' } }); + + await expect(load(deps)).rejects.toThrow('Check them in first.'); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(lines().some((line) => line.startsWith('undo'))).toBe(false); + }); + + it('puts the changes back when the files don’t load once they are aside', async () => { + const { deps, pending, shelves } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true }); + const execute = deps.cm.execute; + deps.cm.execute = async (args, options) => { + if (args[0] === 'update' && Object.keys(pending()).length === 0) throw new Error('The server is unreachable.'); + return execute(args, options); + }; + + await expect(load(deps)).rejects.toThrow('The server is unreachable. Your changes were put back.'); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(shelves()).toEqual([]); + }); +}); diff --git a/src/main/workspace/loadXlink.ts b/src/main/workspace/loadXlink.ts new file mode 100644 index 000000000..01894a1a9 --- /dev/null +++ b/src/main/workspace/loadXlink.ts @@ -0,0 +1,106 @@ +import type { PendingChangesSnapshot } from '@shared/domain/pendingChanges'; +import { CmError } from '../cm/CmError'; +import { onLinksThemselves } from '../cm/symlinkArgs'; +import { loadXlinkArgs } from '../cm/xlinkArgs'; +import { hasPendingChanges } from '../merge/hasPendingChanges'; +import type { OperationContext } from '../operations/OperationTracker'; +import { moveNewItemsAside } from './moveNewItemsAside'; +import { shelvedContents, summarizePending } from './pendingSnapshot'; +import { changesPutBackError, failureReason, putShelvedChangesBack } from './putShelvedChangesBack'; +import { readPendingSnapshot } from './readPendingChanges'; +import { selectorObjectRef } from './selectorObjectRef'; +import type { ShelveFlowDependencies } from './shelveFlowDependencies'; +import { newShelveRecord, NO_TARGET } from './shelveRecord'; +import { switchApproach } from './switchApproach'; +import { describeSelector } from './switchSelectors'; +import { createAutomaticShelve } from './verifiedShelve'; +import { readWorkspaceIdentity } from './workspaceIdentity'; + +const IN_MERGE = 'A merge in progress keeps its files from loading: check the merge in or undo it, then open the xlink’s folder in Files and choose Load its files on it.'; + +/** How a checked-in xlink's files loaded: the changes moved aside for it wait in a shelve when they couldn't merge back cleanly. */ +export type XlinkLoad = { kind: 'loaded' } | { kind: 'changesWaiting'; shelveId: number }; + +/** + * Loads the files of the checked-in xlink at `xlinkPath` (a workspace path) with one `cm update` of the folder holding it + * (`loadXlinkArgs`), which leaves the other pending changes as they are. When `cm` refuses because of them (its + * PendingChangesOnSwitchAction asks to shelve or fail), they are moved aside as a switch moves them, never by `cm`: + * 1. Shelve every change with the official automatic-shelve comment, check the shelve holds them all, and record it + * (`reason: 'xlink'`), so "Welcome back" offers them should they not come back. + * 2. Undo them, move the files they added aside, and load the xlink's files. + * 3. Merge the shelve back: the workspace is at the changeset it was shelved on, so only a change made under the + * xlink's folder meanwhile could conflict. + * If a step before the files load fails, the changes are put back. + */ +export async function loadXlink(deps: ShelveFlowDependencies, workspacePath: string, xlinkPath: string, context: OperationContext): Promise { + const { cm } = deps; + const load = () => cm.execute(loadXlinkArgs(workspacePath, xlinkPath), { cwd: workspacePath }); + try { + await load(); + return { kind: 'loaded' }; + } catch (error) { + if (!(error instanceof CmError)) throw error; + // Only the pending changes tell `cm`'s refusal apart from any other failure: its words are localized. + const snapshot = await readPendingSnapshot(cm, workspacePath); + switch (switchApproach(summarizePending(snapshot.changes))) { + case 'switchAsIs': + throw error; + case 'refuseMerge': + throw new Error(IN_MERGE); + case 'undoUnchangedCheckouts': + await cm.query(onLinksThemselves('undo', '--unchanged', '-r', workspacePath), { cwd: workspacePath }); + await load(); + return { kind: 'loaded' }; + case 'shelveChanges': + return loadAroundChanges(deps, workspacePath, snapshot, load, error, context); + } + } +} + +async function loadAroundChanges( + deps: ShelveFlowDependencies, + workspacePath: string, + snapshot: PendingChangesSnapshot, + load: () => Promise, + refusal: CmError, + context: OperationContext, +): Promise { + const { cm, records } = deps; + // Any failure to update with changes pending comes here, not only the refusal. When `cm` fails before the changes + // are shelved (the server is down), nothing changed and what `cm update` said is the failure to tell; the app's own + // reasons the changes can't be shelved ("Check them in first") stay, as they say what to do. + const shelve = await asideOrRefusal(refusal, async () => { + const workspace = await readWorkspaceIdentity(cm, workspacePath); + const objectRef = await selectorObjectRef(cm, workspacePath, workspace.selector); + if (!objectRef) throw new Error(`Couldn't find ${describeSelector(workspace.selector)} in the repository, so your changes stayed as they were.`); + context.beginStep('Putting your changes aside', 1, 3); + return { workspace, objectRef, created: await createAutomaticShelve(cm, workspacePath, snapshot.changes, objectRef, context) }; + }); + const { workspace, objectRef } = shelve; + const record = newShelveRecord(workspace, shelve.created.id, shelvedContents(snapshot), { mode: 'leave', reason: 'xlink', objectRef, target: NO_TARGET }); + records.save(record); + + // From here on the changes live in the shelve: a failure puts them back. + try { + // Links too: without `--symlink` a checked-out link stays pending (and its target would be undone instead). + await cm.execute(onLinksThemselves('undo', '-r', workspacePath), { cwd: workspacePath }); + await moveNewItemsAside(cm, records, workspacePath, snapshot.changes, record, deps.backupsRoot); + if (await hasPendingChanges(cm, workspacePath)) throw new Error('Some changes are still pending after undoing them'); + context.beginStep('Loading its files', 2, 3); + await load(); + } catch (error) { + throw await changesPutBackError(deps, workspacePath, record, failureReason(error), context); + } + + context.beginStep('Bringing your changes back', 3, 3); + const broughtBack = await putShelvedChangesBack(cm, deps.leftChanges, workspacePath, record, context).catch(() => false); + return broughtBack ? { kind: 'loaded' } : { kind: 'changesWaiting', shelveId: record.shelveId }; +} + +async function asideOrRefusal(refusal: CmError, step: () => Promise): Promise { + try { + return await step(); + } catch (error) { + throw error instanceof CmError ? refusal : error; + } +} diff --git a/src/main/workspace/putShelvedChangesBack.ts b/src/main/workspace/putShelvedChangesBack.ts index e81036d92..f86718166 100644 --- a/src/main/workspace/putShelvedChangesBack.ts +++ b/src/main/workspace/putShelvedChangesBack.ts @@ -48,6 +48,21 @@ export async function putBackAfterFailure( } } +/** + * The error of a step that failed once the changes were shelved and undone (updating, loading an xlink's files): + * `failure`, then whether the changes were put back (`putBackAfterFailure`) or where they wait. + */ +export async function changesPutBackError( + deps: Pick, + workspacePath: string, + record: SwitchShelveRecord, + failure: string, + context: OperationContext, +): Promise { + if (await putBackAfterFailure(deps, workspacePath, record, context)) return new Error(`${failure}. Your changes were put back.`); + return new Error(`${failure}. Your changes are safe in shelve ${record.shelveId}; restore them from Changes.`); +} + /** What went wrong, without its final period, for the sentence that goes on to say where the changes are. */ export function failureReason(cause: unknown): string { return (cause instanceof Error ? cause.message : String(cause)).replace(/\.$/, ''); diff --git a/src/main/workspace/shelveBlockedAndUpdate.ts b/src/main/workspace/shelveBlockedAndUpdate.ts index cf87bc281..28db6f0be 100644 --- a/src/main/workspace/shelveBlockedAndUpdate.ts +++ b/src/main/workspace/shelveBlockedAndUpdate.ts @@ -1,5 +1,4 @@ import type { ShelvedForUpdate, UpdateResolutions } from '@shared/domain/incoming'; -import type { SwitchShelveRecord } from '@shared/domain/switchWithChanges'; import { readUpdateProgress } from '../cm/progress/updateProgress'; import { onLinksThemselves } from '../cm/symlinkArgs'; import { UPDATE_ARGS } from '../cm/updateArgs'; @@ -8,7 +7,7 @@ import { readIncomingChanges } from '../merge/incoming'; import { unresolvedConflicts, updateMergingConflicts } from '../merge/updateWithMerge'; import type { OperationContext } from '../operations/OperationTracker'; import { shelvedContents } from './pendingSnapshot'; -import { failureReason, putBackAfterFailure } from './putShelvedChangesBack'; +import { changesPutBackError, failureReason } from './putShelvedChangesBack'; import { readPendingSnapshot } from './readPendingChanges'; import { selectorObjectRef } from './selectorObjectRef'; import type { ShelveFlowDependencies } from './shelveFlowDependencies'; @@ -62,13 +61,6 @@ export async function shelveBlockedAndUpdate( await cm.execute(UPDATE_ARGS, { cwd: workspacePath, onOutputLine: context.progressOf(readUpdateProgress) }); return { ...result, updated: true, backupDirectory: null }; } catch (error) { - throw await updateFailed(deps, workspacePath, record, error, context); + throw await changesPutBackError(deps, workspacePath, record, `Couldn't update: ${failureReason(error)}`, context); } } - -/** Puts the shelved files back after a failed undo or update, and says where they are. */ -async function updateFailed(deps: ShelveFlowDependencies, workspacePath: string, record: SwitchShelveRecord, cause: unknown, context: OperationContext): Promise { - const failure = `Couldn't update: ${failureReason(cause)}`; - if (await putBackAfterFailure(deps, workspacePath, record, context)) return new Error(`${failure}. Your changes were put back.`); - return new Error(`${failure}. Your changes are safe in shelve ${record.shelveId}; restore them from Changes.`); -} diff --git a/src/main/workspace/shelveFlowDependencies.ts b/src/main/workspace/shelveFlowDependencies.ts index 9125a98f3..d21f10ce8 100644 --- a/src/main/workspace/shelveFlowDependencies.ts +++ b/src/main/workspace/shelveFlowDependencies.ts @@ -2,7 +2,7 @@ import type { CmClient } from '../cm/CmClient'; import type { LeftChangesFinder } from './leftChanges'; import type { SwitchShelveRecords } from './switchShelveRecords'; -/** What every flow that shelves changes out of the workspace (switch, shelve away, update) works with. */ +/** What every flow that shelves changes out of the workspace (switch, shelve away, update, loading an xlink) works with. */ export interface ShelveFlowDependencies { cm: CmClient; records: SwitchShelveRecords; diff --git a/src/main/workspace/testing/playAlongWorkspace.ts b/src/main/workspace/testing/playAlongWorkspace.ts index 5861d6b15..bce79cfb7 100644 --- a/src/main/workspace/testing/playAlongWorkspace.ts +++ b/src/main/workspace/testing/playAlongWorkspace.ts @@ -40,6 +40,11 @@ export interface WorkspaceScenario { shelvesOnServer?: { id: number; comment: string; changes: Record }[]; /** Branches `cm find branch` finds; every branch of `BRANCH_IDS` by default. */ knownBranches?: string[]; + /** + * `cm update ` refuses while changes are pending, as a `cm` whose PendingChangesOnSwitchAction asks to shelve + * them does with `--machinereadable`; otherwise it updates the folder and leaves them, as by default. + */ + updateRefusesPendingChanges?: boolean; fail?: { /** `cm shelveset create` leaves this path out of the shelve. */ shelveMisses?: string; @@ -56,6 +61,8 @@ export interface WorkspaceScenario { shelveConflicts?: number; /** Merging a shelve into the workspace fails. */ shelveMerge?: string; + /** `cm update` fails with these words. */ + update?: string; }; /** A private file the switch finds in the way of a file it writes, and renames `.private.0`. */ privateInTheWay?: string; @@ -94,6 +101,8 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce const privatePaths = new Set(Object.keys(pending).filter((path) => pending[path] === 'PR')); const shelves = new Map((scenario.shelvesOnServer ?? []).map(({ id, comment, changes }) => [id, { comment, changes, addedContents: {} }])); const deletedShelves: number[] = []; + /** The folders `cm update` updated, as workspace paths. */ + const updatedFolders: string[] = []; let nextShelveId = 7; const onDisk = (path: string): string => join(workspacePath, ...path.split('/')); @@ -152,6 +161,16 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce return ''; }; + const update = ({ args }: FakeCmCommand): string | CmFailure => { + if (fail.update) return cmFails(fail.update); + if (scenario.updateRefusesPendingChanges && Object.values(pending).some((codes) => codes !== 'PR')) { + return cmFails('The workspace cannot be updated since there are pending changes. Please review the pending changes and retry the operation again.'); + } + const folder = args.slice(1).find((arg) => !arg.startsWith('-')); + updatedFolders.push(folder ? relative(folder) : ''); + return ''; + }; + const mergeShelve = ({ args }: FakeCmCommand): string | CmFailure => { const shelve = shelves.get(Number(args[1]!.slice('sh:'.length)))!; if (args.includes('--merge')) { @@ -198,6 +217,7 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce .join(''), undo, switch: switchTo, + update, merge: mergeShelve, 'lock list': () => formatOutput( @@ -224,6 +244,7 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce shelves: (): number[] => [...shelves.keys()], shelveComment: (id: number): string | undefined => shelves.get(id)?.comment, deletedShelves, + updatedFolders, changelists: () => changelists, }; } diff --git a/src/renderer/src/components/menuWords.ts b/src/renderer/src/components/menuWords.ts index 9b6aca6fd..7721ec202 100644 --- a/src/renderer/src/components/menuWords.ts +++ b/src/renderer/src/components/menuWords.ts @@ -28,6 +28,7 @@ import { FilePlus, Files, Filter, + FolderDown, FolderGit2, FolderInput, FolderLock, @@ -170,6 +171,7 @@ export const MENU_WORDS = { // An item deleted from disk and a new one paired by hand as one moved item (`pickMoveMatch`). searchMatches: word('act', FileSearch, 'Search matches…'), checkout: word('act', PenLine, 'Check out'), + loadXlink: word('act', FolderDown, 'Load its files'), status: word('act', CircleDot, 'Set status'), assign: word('act', UserPlus, 'Assign reviewer…'), // Sync: the words name the other repository ("Push to game@cloud"). @@ -296,6 +298,7 @@ export const NEEDS_A_WORKSPACE = [ 'restoreTo', 'add', 'checkout', + 'loadXlink', 'merge', 'mergeTask', 'mergeTo', diff --git a/src/renderer/src/features/files/fileMenu.ts b/src/renderer/src/features/files/fileMenu.ts index b8e2123eb..d08b33284 100644 --- a/src/renderer/src/features/files/fileMenu.ts +++ b/src/renderer/src/features/files/fileMenu.ts @@ -29,8 +29,8 @@ import { useFilesViewStore } from './filesViewStore'; import type { PendingChangesIndex } from './itemStatus'; import { isWorkspaceRoot } from './workspaceRoot'; import { listedItems } from './directoryListing'; -import { editableXlinkOf, isUnderXlink, newXlinkDirectoryOf, type XlinkSurroundings } from './xlinkItem'; -import { createXlink, editXlink } from '../xlinks/xlinkOperations'; +import { editableXlinkOf, isUnderXlink, newXlinkDirectoryOf, unloadedXlinkOf, type XlinkSurroundings } from './xlinkItem'; +import { createXlink, editXlink, loadXlinkFiles } from '../xlinks/xlinkOperations'; import { hotkey } from '../../lib/shortcutRegistry'; export const FILE_SHORTCUTS = { @@ -91,6 +91,8 @@ export function fileMenu(workspacePath: string, items: TreeItem[], pendingChange const surroundings = single && xlinkSurroundingsOf(workspacePath, single); const xlinkDirectory = single && surroundings && newXlinkDirectoryOf(single, surroundings); const editedXlink = single && surroundings && editableXlinkOf(single, surroundings); + const unloadedXlink = + single && surroundings && unloadedXlinkOf(single, surroundings, { added: isAdded(pendingChanges, single.path), listedItems: listedItems(workspacePath, single.path) }); // The workspace root can't be renamed or deleted from here. const hasRoot = items.some(isWorkspaceRoot); @@ -100,6 +102,7 @@ export function fileMenu(workspacePath: string, items: TreeItem[], pendingChange label: privateItems.some((item) => item.itemType === 'directory') ? 'Add to version control (recursively)' : 'Add to version control', }), checkoutCandidates.length > 0 && menuAction('checkout', () => void checkoutItems(workspacePath, checkoutCandidates)), + unloadedXlink && menuAction('loadXlink', () => void loadXlinkFiles(workspacePath, unloadedXlink.path)), single && menuAction('newFile', () => void createItem(workspacePath, directory, 'file'), { shortcut: FILE_SHORTCUTS.newFile }), single && menuAction('newFolder', () => void createItem(workspacePath, directory, 'directory'), { shortcut: FILE_SHORTCUTS.newFolder }), xlinkDirectory !== null && menuAction('newXlink', () => createXlink(workspacePath, xlinkDirectory)), diff --git a/src/renderer/src/features/files/xlinkItem.test.ts b/src/renderer/src/features/files/xlinkItem.test.ts index 0dc909ba0..f215be18c 100644 --- a/src/renderer/src/features/files/xlinkItem.test.ts +++ b/src/renderer/src/features/files/xlinkItem.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { detailsKindOf, editableXlinkOf, iconTypeOf, isUnderXlink, newXlinkDirectoryOf, xlinkDetailOf } from './xlinkItem'; +import { detailsKindOf, editableXlinkOf, iconTypeOf, isUnderXlink, newXlinkDirectoryOf, unloadedXlinkOf, xlinkDetailOf } from './xlinkItem'; const xlink = { writable: true, path: '/', changeset: 27649, repository: 'pnunit', server: 'codice@cloud' }; @@ -61,6 +61,18 @@ describe('Creating and editing xlinks from Files', () => { expect(editableXlinkOf({ isPrivate: false, xlink }, { underXlink: true })).toBeNull(); }); + it('loads the files of a checked-in xlink whose folder is listed empty, and of no other', () => { + const item = { isPrivate: false, xlink }; + expect(unloadedXlinkOf(item, inWorkspace, { added: false, listedItems: [] })).toEqual(item); + expect(unloadedXlinkOf(item, inWorkspace, { added: false, listedItems: [{ path: 'code/lib/src' }] })).toBeNull(); + // Not read yet: nothing tells it is empty. + expect(unloadedXlinkOf(item, inWorkspace, { added: false, listedItems: undefined })).toBeNull(); + // Not checked in: no command can load it yet. + expect(unloadedXlinkOf(item, inWorkspace, { added: true, listedItems: [] })).toBeNull(); + expect(unloadedXlinkOf({ isPrivate: false }, inWorkspace, { added: false, listedItems: [] })).toBeNull(); + expect(unloadedXlinkOf(item, { underXlink: true }, { added: false, listedItems: [] })).toBeNull(); + }); + it('tells an item under an xlink from the folders listed above it', () => { const listings = new Map([ ['', [{ path: 'code' }]], diff --git a/src/renderer/src/features/files/xlinkItem.ts b/src/renderer/src/features/files/xlinkItem.ts index c6e1de03f..d0f5bfa84 100644 --- a/src/renderer/src/features/files/xlinkItem.ts +++ b/src/renderer/src/features/files/xlinkItem.ts @@ -68,3 +68,19 @@ export function newXlinkDirectoryOf(item: Pick>(item: Item, where: Pick): (Item & { xlink: XlinkTarget }) | null { return item.xlink && !item.isPrivate && !where.underXlink ? (item as Item & { xlink: XlinkTarget }) : null; } + +/** + * The item as a checked-in xlink whose files aren't loaded: its folder listed, and empty. `cm` fills an xlink's folder + * only once it is checked in and the folder updated (docs/features/xlinks.md): one checked in from Changes, or whose + * files didn't load as it was created, stays empty until "Load its files". Not while it is added, as nothing can load + * it yet; nor before its folder is listed, as an unread listing tells nothing. An xlink to an empty folder of its + * repository is offered it too: loading it again changes nothing. + */ +export function unloadedXlinkOf>( + item: Item, + where: Pick, + { added, listedItems }: { added: boolean; listedItems: readonly unknown[] | undefined }, +): (Item & { xlink: XlinkTarget }) | null { + const xlink = editableXlinkOf(item, where); + return xlink && !added && listedItems?.length === 0 ? xlink : null; +} diff --git a/src/renderer/src/features/leftChanges/leftChangesOperations.ts b/src/renderer/src/features/leftChanges/leftChangesOperations.ts index e37f4dd35..f9f365f1e 100644 --- a/src/renderer/src/features/leftChanges/leftChangesOperations.ts +++ b/src/renderer/src/features/leftChanges/leftChangesOperations.ts @@ -39,7 +39,7 @@ export async function restoreLeftChanges(workspacePath: string, left: LeftChange } function restoredMessage(left: LeftChanges, count: number, sourceName: string): string { - return left.reason === 'update' ? `Restored ${pluralize(count, 'change')} you put aside` : `Restored ${pluralize(count, 'change')} you left on ${branchLabel(sourceName)}`; + return left.reason !== 'switch' ? `Restored ${pluralize(count, 'change')} you put aside` : `Restored ${pluralize(count, 'change')} you left on ${branchLabel(sourceName)}`; } export function reviewLeftChanges(left: LeftChanges): void { diff --git a/src/renderer/src/features/leftChanges/leftChangesWords.test.ts b/src/renderer/src/features/leftChanges/leftChangesWords.test.ts index ea7920488..13dfc373a 100644 --- a/src/renderer/src/features/leftChanges/leftChangesWords.test.ts +++ b/src/renderer/src/features/leftChanges/leftChangesWords.test.ts @@ -40,6 +40,11 @@ describe('Welcome back', () => { ); expect(leftChangesDetail(left({ reason: 'update' }), NOW)).toContain('deleted or moved the files.'); }); + + it('tells changes put aside to load an xlink’s files, which no branch deleted or moved', () => { + expect(leftChangesTitle(left({ reason: 'xlink' }))).toBe('3 changes put aside to load an xlink'); + expect(leftChangesDetail(left({ reason: 'xlink' }), NOW)).toBe('Shelved 2 hours ago (shelve 12) to load an xlink’s files. Restoring merges your changes back.'); + }); }); describe('the dot on Changes', () => { @@ -58,5 +63,7 @@ describe('the dot on Changes', () => { it('tells changes put aside to update', () => { expect(leftChangesSummary([left({ reason: 'update' })])).toBe('Changes put aside to update · restore them in Changes'); + expect(leftChangesSummary([left({ reason: 'xlink' })])).toBe('Changes put aside to load an xlink · restore them in Changes'); + expect(leftChangesSummary([left({ reason: 'update' }), left({ shelveId: 11, reason: 'xlink' })])).toBe('Changes put aside · restore them in Changes'); }); }); diff --git a/src/renderer/src/features/leftChanges/leftChangesWords.ts b/src/renderer/src/features/leftChanges/leftChangesWords.ts index a25cbd5e1..df0f0b2a9 100644 --- a/src/renderer/src/features/leftChanges/leftChangesWords.ts +++ b/src/renderer/src/features/leftChanges/leftChangesWords.ts @@ -3,9 +3,12 @@ import { branchLabel } from '../../lib/branchLabels'; import { formatRelativeDate } from '../../lib/formatDate'; import { pluralize } from '../../lib/text'; +/** What changes the app put aside, not left by a switch, were put aside for. */ +const PUT_ASIDE_FOR: Record, string> = { update: 'to update', xlink: 'to load an xlink' }; + /** The "Welcome back" banner's title: what waits here, and where it was left (the branch by its own name, `branchLabel`). */ export function leftChangesTitle(left: LeftChanges): string { - if (left.reason === 'update') return `${pluralize(left.count, 'change')} put aside to update`; + if (left.reason !== 'switch') return `${pluralize(left.count, 'change')} put aside ${PUT_ASIDE_FOR[left.reason]}`; return left.mode === 'bring' ? `Your changes from ${branchLabel(left.sourceName)} are waiting to be brought here` : `Welcome back — you left ${pluralize(left.count, 'change')} on ${branchLabel(left.sourceName)}`; @@ -16,12 +19,14 @@ export function leftChangesDetail(left: LeftChanges, now = Date.now()): string { const shelved = `Shelved ${formatRelativeDate(left.createdAt, now)} (shelve ${left.shelveId})`; if (left.foreign) return `${shelved}, left from another workspace or app.`; if (left.reason === 'update') return `${shelved}: ${branchLabel(left.sourceName)} deleted or moved ${left.count === 1 ? 'the file' : 'the files'}. Restoring merges your changes back.`; + if (left.reason === 'xlink') return `${shelved} to load an xlink’s files. Restoring merges your changes back.`; if (left.mode === 'bring') return `${shelved} when you switched here. Some files need your decision.`; return left.targetName ? `${shelved} when you switched to ${branchLabel(left.targetName)}.` : `${shelved}.`; } const WHERE_TO_RESTORE = 'restore them in Changes'; + /** * What the dot on Changes in the sidebar says: what waits and where it was left, in the banner's words; undefined when * nothing waits. @@ -31,6 +36,7 @@ export function leftChangesSummary(left: readonly LeftChanges[]): string | undef if (!newest) return undefined; const branches = new Set(left.map((each) => each.sourceName)).size; if (branches > 1) return `Changes left on ${pluralize(branches, 'branch', 'branches')} · ${WHERE_TO_RESTORE}`; - if (left.every((each) => each.reason === 'update')) return `Changes put aside to update · ${WHERE_TO_RESTORE}`; + const reasons = new Set(left.map((each) => each.reason)); + if (!reasons.has('switch')) return reasons.size === 1 && newest.reason !== 'switch' ? `Changes put aside ${PUT_ASIDE_FOR[newest.reason]} · ${WHERE_TO_RESTORE}` : `Changes put aside · ${WHERE_TO_RESTORE}`; return `Changes left on ${branchLabel(newest.sourceName)} · ${WHERE_TO_RESTORE}`; } diff --git a/src/renderer/src/features/shelves/myShelves.test.ts b/src/renderer/src/features/shelves/myShelves.test.ts index 85d89617b..234bea576 100644 --- a/src/renderer/src/features/shelves/myShelves.test.ts +++ b/src/renderer/src/features/shelves/myShelves.test.ts @@ -46,6 +46,11 @@ describe('myShelves', () => { expect(updated).toMatchObject({ title: 'Put aside to update task', left: true }); }); + it('names changes put aside to load an xlink by what they were put aside for', () => { + const [loading] = myShelves([shelve(13, automatic)], [record(13, 'xlink')], MINE, NOW); + expect(loading).toMatchObject({ title: 'Put aside to load an xlink on task', left: true }); + }); + it("recognizes another app's automatic shelves as left changes", () => { expect(myShelves([shelve(12, automatic)], [], MINE, NOW)[0]).toMatchObject({ title: 'Left when switching', left: true }); }); diff --git a/src/renderer/src/features/shelves/myShelves.ts b/src/renderer/src/features/shelves/myShelves.ts index 52b4dd8e0..943cf0a5f 100644 --- a/src/renderer/src/features/shelves/myShelves.ts +++ b/src/renderer/src/features/shelves/myShelves.ts @@ -60,7 +60,9 @@ function sameUser(owner: string, me: string): boolean { function leftTitle(record: SwitchShelveRecord): string { const source = branchLabel(record.source.name); - return record.reason === 'update' ? `Put aside to update ${source}` : `Left on ${source}`; + if (record.reason === 'update') return `Put aside to update ${source}`; + if (record.reason === 'xlink') return `Put aside to load an xlink on ${source}`; + return `Left on ${source}`; } /** Whether the shelve shows for the words typed: each in its number, its comment, what it's called here or its author, in any case. */ diff --git a/src/renderer/src/features/xlinks/XlinkDialog.tsx b/src/renderer/src/features/xlinks/XlinkDialog.tsx index 97659fdee..292a5ad6e 100644 --- a/src/renderer/src/features/xlinks/XlinkDialog.tsx +++ b/src/renderer/src/features/xlinks/XlinkDialog.tsx @@ -9,7 +9,7 @@ import { openDialog } from '../../ui/dialog/dialogStore'; import fieldStyles from '../../ui/Field.module.css'; import { IconButton } from '../../ui/IconButton'; import { SegmentedControl } from '../../ui/SegmentedControl'; -import { TextField } from '../../ui/TextField'; +import { TextArea, TextField } from '../../ui/TextField'; import { PickField } from './PickField'; import styles from './XlinkDialog.module.css'; import { @@ -96,6 +96,7 @@ function XlinkDialog({ target, save, onClose }: { target: XlinkDialogTarget; sav change({ folder })} /> )} {fields.showsRules && } + {fields.isNew &&