Compute diff URLs in the view model

This fixes a bug where `checksPatchset` would be reset when navigating
to a diff view.

Generally, when we are constsructing a diff URL we are typically
interested into computing a URL relative to the current state. We do
not want to look up and set all current view properties in every
code location that creates a diff URL. So we move the construction
of diff URLs into the view model itself and just provide
the delta as an override.

Release-Notes: skip
Google-Bug-Id: b/345851627
Change-Id: Ib13529a50c8a952e41eb605e01f654b31dace5c6
(cherry picked from commit 3a5bb62097d88dbfe523a9bd23af4147935ad47d)
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index ed42b3b..781993e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -145,7 +145,6 @@
   changeViewModelToken,
   ChangeViewState,
   createChangeUrl,
-  createEditUrl,
 } from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {userModelToken} from '../../../models/user/user-model';
@@ -2399,14 +2398,8 @@
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        assertIsDefined(this.patchNum, 'patchset number');
         this.getNavigation().setUrl(
-          createEditUrl({
-            changeNum: this.change._number,
-            repo: this.change.project,
-            patchNum: this.patchNum,
-            editView: {path},
-          })
+          this.getViewModel().editUrl({editView: {path}})
         );
         break;
       case GrEditConstants.Actions.RENAME.id:
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 1fd4aa2..7b9c291 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -73,7 +73,10 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView} from '../../../models/views/change';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -308,6 +311,12 @@
 
   setup(async () => {
     setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'editUrl')
+      .returns('fakeEditUrl');
+    sinon
+      .stub(testResolver(changeViewModelToken), 'diffUrl')
+      .returns('fakeDiffUrl');
 
     stubRestApi('getConfig').returns(
       Promise.resolve({
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 7a09518..4ceca90 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -80,9 +80,8 @@
 import {incrementalRepeat} from '../../lit/incremental-repeat';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {
-  createDiffUrl,
-  createEditUrl,
   createChangeUrl,
+  changeViewModelToken,
 } from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -212,7 +211,7 @@
   diffViewMode?: DiffViewMode;
 
   @property({type: Boolean})
-  editMode?: boolean;
+  editMode = false;
 
   private _filesExpanded = FilesExpandedState.NONE;
 
@@ -313,6 +312,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // private but used in test
   fileCursor = new GrCursorManager();
 
@@ -2171,10 +2172,7 @@
       throw new Error('change, diff and patchRange must be all set and valid');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: diff.path},
       })
     );
@@ -2192,10 +2190,7 @@
       throw new Error('change and patchRange must be set');
     }
     this.getNavigation().setUrl(
-      createDiffUrl({
-        change: this.change,
-        patchNum: this.patchRange.patchNum,
-        basePatchNum: this.patchRange.basePatchNum,
+      this.getViewModel().diffUrl({
         diffView: {path: this.files[this.fileCursor.index].__path},
       })
     );
@@ -2214,31 +2209,16 @@
     );
   }
 
+  /** Returns an edit or diff URL depending on `editMode`. */
   // Private but used in tests
-  computeDiffURL(path?: string) {
-    if (
-      this.change === undefined ||
-      this.patchRange?.patchNum === undefined ||
-      path === undefined ||
-      this.editMode === undefined
-    ) {
+  computeDiffURL(path?: string): string | undefined {
+    if (path === undefined) {
       return;
     }
     if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return createEditUrl({
-        changeNum: this.change._number,
-        repo: this.change.project,
-        patchNum: this.patchRange.patchNum,
-        editView: {path},
-      });
+      return this.getViewModel().editUrl({editView: {path}});
     }
-    return createDiffUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchRange.patchNum,
-      basePatchNum: this.patchRange.basePatchNum,
-      diffView: {path},
-    });
+    return this.getViewModel().diffUrl({diffView: {path}});
   }
 
   // Private but used in tests.
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index eec829a..7f51a40 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -60,6 +60,11 @@
 import {FileMode} from '../../../utils/file-util';
 import {SinonStubbedMember} from 'sinon';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -79,6 +84,16 @@
   let element: GrFileList;
   let saveStub: sinon.SinonStub;
 
+  setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+      patchNum: 1 as RevisionPatchSetNum,
+    });
+  });
+
   suite('basic tests', async () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -179,7 +194,7 @@
             <gr-file-status></gr-file-status>
           </div>
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/1/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -299,7 +314,7 @@
         fileRows[0].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/1/path/file0">
               <span class="fullFileName" title="path/file0">
                 <span class="newFilePath"> path/ </span>
                 <span class="fileName"> file0 </span>
@@ -317,7 +332,7 @@
         fileRows[1].querySelector('.path'),
         /* HTML */ `
           <span class="path" role="gridcell">
-            <a class="pathLink">
+            <a class="pathLink" href="/c/gerrit/+/42/1/path/file1">
               <span class="fullFileName" title="path/file1">
                 <span class="matchingFilePath"> path/ </span>
                 <span class="fileName"> file1 </span>
@@ -947,10 +962,10 @@
         ];
         element.changeNum = 42 as NumericChangeId;
         element.basePatchNum = PARENT;
-        element.patchNum = 2 as RevisionPatchSetNum;
+        element.patchNum = 1 as RevisionPatchSetNum;
         element.change = {
           _number: 42 as NumericChangeId,
-          project: 'test-project',
+          project: 'gerrit',
         } as ParsedChangeInfo;
         element.fileCursor.setCursorAtIndex(0);
         await element.updateComplete;
@@ -1009,7 +1024,7 @@
         assert.equal(setUrlStub.callCount, 1);
         assert.equal(
           setUrlStub.lastCall.firstArg,
-          '/c/test-project/+/42/2/file_added_in_rev2.txt'
+          '/c/gerrit/+/42/1/file_added_in_rev2.txt'
         );
 
         pressKey(element, 'k');
@@ -1705,35 +1720,24 @@
 
   suite('diff url file list', () => {
     test('diff url', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
       const path = 'index.php';
       element.editMode = false;
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/42/1/index.php');
     });
 
     test('diff url commit msg', () => {
-      element.change = {
-        ...createParsedChange(),
-        _number: 1 as NumericChangeId,
-        project: 'gerrit' as RepoName,
-      };
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      element.editMode = false;
       const path = '/COMMIT_MSG';
-      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
+      element.editMode = false;
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/42/1//COMMIT_MSG'
+      );
     });
 
     test('edit url', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1742,14 +1746,14 @@
       const path = 'index.php';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1/index.php,edit'
+        '/c/gerrit/+/42/1/index.php,edit'
       );
     });
 
     test('edit url commit msg', () => {
       element.change = {
         ...createParsedChange(),
-        _number: 1 as NumericChangeId,
+        _number: 42 as NumericChangeId,
         project: 'gerrit' as RepoName,
       };
       element.basePatchNum = PARENT;
@@ -1758,7 +1762,7 @@
       const path = '/COMMIT_MSG';
       assert.equal(
         element.computeDiffURL(path),
-        '/c/gerrit/+/1/1//COMMIT_MSG,edit'
+        '/c/gerrit/+/42/1//COMMIT_MSG,edit'
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 72d3268..d0e44c5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -58,6 +58,7 @@
 import {
   DropdownLink,
   LabelNameToInfoMap,
+  PARENT,
   PatchSetNumber,
 } from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
@@ -78,7 +79,7 @@
 import {when} from 'lit/directives/when.js';
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
-import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+import {changeViewModelToken} from '../../models/views/change';
 import {formStyles} from '../../styles/form-styles';
 
 /**
@@ -635,6 +636,8 @@
 
   private getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -719,9 +722,8 @@
       return {
         icon: LinkIcon.CODE,
         tooltip: `${path}${rangeText}`,
-        url: createDiffUrl({
-          changeNum: change._number,
-          repo: change.project,
+        url: this.getViewModel().diffUrl({
+          basePatchNum: PARENT,
           patchNum: patchset,
           checksPatchset: patchset,
           diffView: {path, lineNum: line},
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index ee2a806..81442c3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -92,7 +92,6 @@
 import {when} from 'lit/directives/when.js';
 import {styleMap} from 'lit/directives/style-map.js';
 import {
-  createDiffUrl,
   ChangeChildView,
   changeViewModelToken,
 } from '../../../models/views/change';
@@ -1457,7 +1456,7 @@
     if (!newPath) return;
     if (newPath.up) return this.getChangeModel().changeUrl();
     if (!newPath.path) return;
-    return this.getChangeModel().diffUrl({path: newPath.path});
+    return this.getViewModel().diffUrl({diffView: {path: newPath.path}});
   }
 
   private goToEditFile() {
@@ -1504,15 +1503,8 @@
   }
 
   private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    if (!this.changeNum) return;
     if (!this.path) return;
-    const url = createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      basePatchNum: this.basePatchNum,
+    const url = this.getViewModel().diffUrl({
       diffView: {
         path: this.path,
         lineNum,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 93424b1..96467b4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -137,9 +137,9 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
-      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       viewModel = testResolver(changeViewModelToken);
       viewModel.setState(createDiffViewState());
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
       await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
       element.path = 'some/path.txt';
       element.change = createParsedChange();
@@ -188,16 +188,18 @@
 
     test('renders', async () => {
       browserModel.setScreenWidth(0);
-      element.patchNum = 10 as RevisionPatchSetNum;
+      const patchNum = 10 as RevisionPatchSetNum;
+      element.patchNum = patchNum;
       element.basePatchNum = PARENT;
       const change = {
         ...createParsedChange(),
         _number: 42 as NumericChangeId,
         revisions: {
-          a: createRevision(10),
+          a: createRevision(patchNum),
         },
       };
       changeModel.updateStateChange(change);
+      viewModel.updateState({patchNum});
       element.files = getFilesFromFileList([
         'chell.go',
         'glados.txt',
@@ -1012,14 +1014,16 @@
       });
 
       test('prev/up/next links', async () => {
+        const patchNum = 10 as RevisionPatchSetNum;
         viewModel.setState({
           ...createDiffViewState(),
+          patchNum,
         });
         const change = {
           ...createParsedChange(),
           _number: 42 as NumericChangeId,
           revisions: {
-            a: createRevision(10),
+            a: createRevision(patchNum),
           },
         };
         changeModel.updateStateChange(change);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index 1e871ce..d6f55ff 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -29,7 +29,7 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
 import {resolve} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {whenVisible} from '../../../utils/dom-util';
@@ -81,6 +81,8 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   static override get styles() {
@@ -431,13 +433,7 @@
       return;
     }
     assertIsDefined(this.patchNum, 'patchset number');
-    const url = createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      editView: {path: this.path},
-    });
-
+    const url = this.getViewModel().editUrl({editView: {path: this.path}});
     this.getNavigation().setUrl(url);
     this.closeDialog(this.getDialogFromEvent(e));
   };
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 0e6778a..aacbb36 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -32,6 +32,11 @@
   changeModelToken,
 } from '../../../models/change/change-model';
 import {SinonStubbedMember} from 'sinon';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -45,6 +50,13 @@
   >;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 42 as NumericChangeId,
+      repo: 'gerrit' as RepoName,
+    });
+
     element = await fixture<GrEditControls>(html`
       <gr-edit-controls></gr-edit-controls>
     `);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 0b03581..5bd822b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -70,7 +70,11 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {whenRendered} from '../../../utils/dom-util';
-import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  createChangeUrl,
+  createDiffUrl,
+} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 import {noAwait, waitUntil} from '../../../utils/async-util';
@@ -248,6 +252,8 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -709,9 +715,7 @@
     if (!this.changeNum || !this.repoName || !this.thread?.path) {
       return undefined;
     }
-    return createDiffUrl({
-      changeNum: this.changeNum,
-      repo: this.repoName,
+    return this.getViewModel().diffUrl({
       patchNum: this.thread.patchNum,
       diffView: {path: this.thread.path},
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 571a322..b56fcfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -38,6 +38,11 @@
   commentsModelToken,
 } from '../../../models/comments/comments-model';
 import {testResolver} from '../../../test/common-test-setup';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
 
 const c1: CommentInfo = {
   author: {name: 'Kermit'},
@@ -80,6 +85,12 @@
   let element: GrCommentThread;
 
   setup(async () => {
+    testResolver(changeViewModelToken).setState({
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: 1 as NumericChangeId,
+      repo: 'test-repo-name' as RepoName,
+    });
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
     element.changeNum = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 3964422..c1a76a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -43,7 +43,7 @@
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {resolve} from '../../../models/dependency';
 import {formStyles} from '../../../styles/form-styles';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
 import {SpecialFilePath} from '../../../constants/constants';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
@@ -128,6 +128,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
@@ -501,10 +503,7 @@
       assertIsDefined(this.changeNum, 'changeNum');
       assertIsDefined(this.repoName, 'repoName');
       this.getNavigation().setUrl(
-        createEditUrl({
-          changeNum: this.changeNum,
-          repo: this.repoName,
-          patchNum: this.patchNum,
+        this.getViewModel().editUrl({
           editView: {path: SpecialFilePath.COMMIT_MESSAGE},
         })
       );
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index e8ccdd1..4243042 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -14,11 +14,8 @@
 import {testResolver} from '../../../test/common-test-setup';
 import {GrDropdownList} from '../gr-dropdown-list/gr-dropdown-list';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-} from '../../../api/rest-api';
+import {NumericChangeId, RepoName} from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
 
 const emails = [
   {
@@ -189,11 +186,15 @@
 
     suite('in editMode', () => {
       test('click opens edit url', async () => {
+        const editUrlStub = sinon.stub(
+          testResolver(changeViewModelToken),
+          'editUrl'
+        );
+        editUrlStub.returns('fakeUrl');
         const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
         element.editMode = true;
         element.changeNum = 42 as NumericChangeId;
         element.repoName = 'Test Repo' as RepoName;
-        element.patchNum = '1' as RevisionPatchSetNum;
         await element.updateComplete;
         const editButton = queryAndAssert<GrButton>(
           element,
@@ -201,10 +202,7 @@
         );
         editButton.click();
         assert.isTrue(setUrlStub.called);
-        assert.equal(
-          setUrlStub.lastCall.args[0],
-          '/c/Test+Repo/+/42/1//COMMIT_MSG,edit'
-        );
+        assert.equal(setUrlStub.lastCall.args[0], 'fakeUrl');
       });
     });
   });
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index bba7e47..815b93a 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -46,8 +46,6 @@
   ChangeChildView,
   ChangeViewModel,
   createChangeUrl,
-  createDiffUrl,
-  createEditUrl,
 } from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
 import {getRevertCreatedChangeIds} from '../../utils/message-util';
@@ -657,27 +655,12 @@
     return this.getState().change;
   }
 
-  diffUrl(
-    diffView: {path: string; lineNum?: number},
-    patchNum = this.patchNum,
-    basePatchNum = this.basePatchNum
-  ) {
-    if (!this.change) return;
-    if (!this.patchNum) return;
-    return createDiffUrl({
-      change: this.change,
-      patchNum,
-      basePatchNum,
-      diffView,
-    });
-  }
-
   navigateToDiff(
     diffView: {path: string; lineNum?: number},
     patchNum = this.patchNum,
     basePatchNum = this.basePatchNum
   ) {
-    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    const url = this.viewModel.diffUrl({diffView, patchNum, basePatchNum});
     if (!url) return;
     this.navigation.setUrl(url);
   }
@@ -715,18 +698,8 @@
     this.navigation.setUrl(url);
   }
 
-  editUrl(editView: {path: string; lineNum?: number}) {
-    if (!this.change) return;
-    return createEditUrl({
-      changeNum: this.change._number,
-      repo: this.change.project,
-      patchNum: this.patchNum,
-      editView,
-    });
-  }
-
   navigateToEdit(editView: {path: string; lineNum?: number}) {
-    const url = this.editUrl(editView);
+    const url = this.viewModel.editUrl({editView});
     if (!url) return;
     this.navigation.setUrl(url);
   }
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 06d981a..13babae 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -11,11 +11,12 @@
   ChangeInfo,
   PatchSetNumber,
   EDIT,
+  PARENT,
 } from '../../api/rest-api';
 import {Tab} from '../../constants/constants';
 import {GerritView} from '../../services/router/router-model';
 import {UrlEncodedCommentId} from '../../types/common';
-import {toggleSet} from '../../utils/common-util';
+import {assertIsDefined, toggleSet} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
 import {
   encodeURL,
@@ -26,6 +27,7 @@
 import {define} from '../dependency';
 import {Model} from '../base/model';
 import {ViewState} from './base';
+import {isNumber} from '../../utils/patch-set-util';
 
 export enum ChangeChildView {
   OVERVIEW = 'OVERVIEW',
@@ -145,7 +147,7 @@
 
 export function createChangeUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.OVERVIEW,
@@ -198,7 +200,7 @@
 
 export function createDiffUrl(
   obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
   const state: ChangeViewState = objToState({
     ...obj,
     childView: ChangeChildView.DIFF,
@@ -378,6 +380,49 @@
     }
   }
 
+  /**
+   * Wrapper around createDiffUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  diffUrl(override: Partial<ChangeViewState>): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    const patchNum = override.patchNum ?? current.patchNum;
+    let basePatchNum = override.basePatchNum ?? current.basePatchNum;
+    if (isNumber(basePatchNum) && isNumber(patchNum)) {
+      if ((patchNum as number) < (basePatchNum as number)) {
+        basePatchNum = PARENT;
+      }
+    }
+    return createDiffUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum,
+      basePatchNum,
+      checksPatchset: override.checksPatchset ?? current.checksPatchset,
+      diffView: override.diffView ?? current.diffView,
+    });
+  }
+
+  /**
+   * Wrapper around createEditUrl() that falls back to the current state for all
+   * properties that are not explicitly provided as an override.
+   */
+  editUrl(override: Partial<ChangeViewState>): string {
+    const current = this.getState();
+    assertIsDefined(current?.changeNum);
+    assertIsDefined(current?.repo);
+
+    return createEditUrl({
+      changeNum: override.changeNum ?? current.changeNum,
+      repo: override.repo ?? current.repo,
+      patchNum: override.patchNum ?? current.patchNum,
+      editView: override.editView ?? current.editView,
+    });
+  }
+
   toggleSelectedCheckRun(checkName: string) {
     const current = this.getState()?.checksRunsSelected ?? new Set();
     const next = new Set(current);
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7a5cd45..dc7adc1 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -70,7 +70,7 @@
   return patchset as PatchSetNum;
 }
 
-export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
+export function isNumber(psn?: PatchSetNum): psn is PatchSetNumber {
   return typeof psn === 'number';
 }