collabmd 0.1.19 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{editor-session-AH6Z3MXW.js → editor-session-TAV2VXTA.js} +11 -11
- package/public/assets/js/main.js +95 -68
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +29 -1
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/bootstrap/collabmd-app-shell.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +64 -6
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-ui-controller.js +41 -5
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +138 -7
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +16 -0
|
@@ -87,6 +87,7 @@ export class GitPanelController {
|
|
|
87
87
|
constructor({
|
|
88
88
|
enabled = true,
|
|
89
89
|
onCommitStaged = () => {},
|
|
90
|
+
onOpenPullBackup = () => {},
|
|
90
91
|
onPullBranch = () => {},
|
|
91
92
|
onPushBranch = () => {},
|
|
92
93
|
onRepoChange = () => {},
|
|
@@ -100,6 +101,7 @@ export class GitPanelController {
|
|
|
100
101
|
} = {}) {
|
|
101
102
|
this.enabled = enabled;
|
|
102
103
|
this.onCommitStaged = onCommitStaged;
|
|
104
|
+
this.onOpenPullBackup = onOpenPullBackup;
|
|
103
105
|
this.onPullBranch = onPullBranch;
|
|
104
106
|
this.onPushBranch = onPushBranch;
|
|
105
107
|
this.onRepoChange = onRepoChange;
|
|
@@ -111,6 +113,7 @@ export class GitPanelController {
|
|
|
111
113
|
this.searchInput = searchInput;
|
|
112
114
|
this.toastController = toastController;
|
|
113
115
|
this.panel = document.getElementById('gitPanel');
|
|
116
|
+
this.pullBackups = [];
|
|
114
117
|
this.status = null;
|
|
115
118
|
this.active = false;
|
|
116
119
|
this.refreshTimer = null;
|
|
@@ -147,6 +150,18 @@ export class GitPanelController {
|
|
|
147
150
|
return;
|
|
148
151
|
}
|
|
149
152
|
|
|
153
|
+
const pullBackupButton = event.target instanceof Element
|
|
154
|
+
? event.target.closest('[data-git-pull-backup-path]')
|
|
155
|
+
: null;
|
|
156
|
+
if (pullBackupButton) {
|
|
157
|
+
const summaryPath = pullBackupButton.getAttribute('data-git-pull-backup-path');
|
|
158
|
+
if (!summaryPath) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.onOpenPullBackup(summaryPath);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
150
165
|
const actionButton = event.target instanceof Element
|
|
151
166
|
? event.target.closest('[data-git-file-action]')
|
|
152
167
|
: null;
|
|
@@ -243,6 +258,7 @@ export class GitPanelController {
|
|
|
243
258
|
sections: [],
|
|
244
259
|
summary: { changedFiles: 0 },
|
|
245
260
|
};
|
|
261
|
+
this.pullBackups = [];
|
|
246
262
|
this.onRepoChange(false, this.status);
|
|
247
263
|
this.render();
|
|
248
264
|
return this.status;
|
|
@@ -256,6 +272,19 @@ export class GitPanelController {
|
|
|
256
272
|
}
|
|
257
273
|
|
|
258
274
|
this.status = data;
|
|
275
|
+
this.pullBackups = [];
|
|
276
|
+
if (data.isGitRepo) {
|
|
277
|
+
try {
|
|
278
|
+
const backupResponse = await fetch(resolveApiUrl('/git/pull-backups'));
|
|
279
|
+
const backupData = await backupResponse.json();
|
|
280
|
+
if (!backupResponse.ok) {
|
|
281
|
+
throw new Error(backupData.error || 'Failed to load pull backups');
|
|
282
|
+
}
|
|
283
|
+
this.pullBackups = Array.isArray(backupData.backups) ? backupData.backups : [];
|
|
284
|
+
} catch (backupError) {
|
|
285
|
+
console.error('[git-panel] Failed to load pull backups:', backupError);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
259
288
|
this.onRepoChange(Boolean(data.isGitRepo), data);
|
|
260
289
|
this.render();
|
|
261
290
|
return data;
|
|
@@ -267,6 +296,7 @@ export class GitPanelController {
|
|
|
267
296
|
sections: [],
|
|
268
297
|
summary: { changedFiles: 0 },
|
|
269
298
|
};
|
|
299
|
+
this.pullBackups = [];
|
|
270
300
|
this.onRepoChange(false, this.status);
|
|
271
301
|
this.render();
|
|
272
302
|
return this.status;
|
|
@@ -367,6 +397,47 @@ export class GitPanelController {
|
|
|
367
397
|
`;
|
|
368
398
|
}
|
|
369
399
|
|
|
400
|
+
renderPullBackupsSection() {
|
|
401
|
+
if (!Array.isArray(this.pullBackups) || this.pullBackups.length === 0) {
|
|
402
|
+
return '';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return `
|
|
406
|
+
<section class="git-section">
|
|
407
|
+
<button class="git-section-header" type="button" data-git-section-toggle="pull-backups">
|
|
408
|
+
${chevronSvg(this.collapsedSections.has('pull-backups'))}
|
|
409
|
+
Pull Backups
|
|
410
|
+
<span class="git-section-count">${this.pullBackups.length}</span>
|
|
411
|
+
</button>
|
|
412
|
+
<div class="git-file-list${this.collapsedSections.has('pull-backups') ? ' hidden' : ''}">
|
|
413
|
+
${this.pullBackups.map((backup) => this.renderPullBackup(backup)).join('')}
|
|
414
|
+
</div>
|
|
415
|
+
</section>
|
|
416
|
+
`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
renderPullBackup(backup) {
|
|
420
|
+
const createdAt = String(backup?.createdAt || '').replace('T', ' ').replace(/\.\d+Z?$/u, 'Z');
|
|
421
|
+
const fileCount = Number(backup?.fileCount || 0);
|
|
422
|
+
|
|
423
|
+
return `
|
|
424
|
+
<div class="git-file-row">
|
|
425
|
+
<button
|
|
426
|
+
class="git-file-item"
|
|
427
|
+
type="button"
|
|
428
|
+
data-git-pull-backup-path="${escapeHtml(backup.summaryPath || '')}"
|
|
429
|
+
>
|
|
430
|
+
${fileIconSvg()}
|
|
431
|
+
<span class="git-file-copy">
|
|
432
|
+
<span class="git-file-name">Pull backup ${escapeHtml(backup.id || '')}</span>
|
|
433
|
+
<span class="git-file-path">${escapeHtml(`${createdAt} · ${backup.branch || 'HEAD'} · ${fileCount} file${fileCount === 1 ? '' : 's'}`)}</span>
|
|
434
|
+
</span>
|
|
435
|
+
<span class="git-status-badge modified">BK</span>
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
|
|
370
441
|
renderFile(file) {
|
|
371
442
|
const isActive = this.selection.path === file.path && this.selection.scope === file.scope;
|
|
372
443
|
const dirPath = getPathDir(file.path);
|
|
@@ -449,6 +520,7 @@ export class GitPanelController {
|
|
|
449
520
|
}
|
|
450
521
|
|
|
451
522
|
const branch = this.status.branch ?? {};
|
|
523
|
+
const pullBackupsMarkup = this.renderPullBackupsSection();
|
|
452
524
|
const sectionMarkup = (this.status.sections ?? [])
|
|
453
525
|
.map((section) => this.renderSection(section))
|
|
454
526
|
.filter(Boolean)
|
|
@@ -492,6 +564,7 @@ export class GitPanelController {
|
|
|
492
564
|
</button>
|
|
493
565
|
</div>
|
|
494
566
|
</div>
|
|
567
|
+
${pullBackupsMarkup}
|
|
495
568
|
${sectionMarkup || '<div class="git-panel-empty">No local changes</div>'}
|
|
496
569
|
${hasChanges ? `
|
|
497
570
|
<div class="git-panel-footer">
|
|
@@ -2,6 +2,7 @@ export class OutlineController {
|
|
|
2
2
|
constructor({
|
|
3
3
|
mobileBreakpointQuery = window.matchMedia('(max-width: 768px)'),
|
|
4
4
|
onNavigateToHeading,
|
|
5
|
+
onWillOpen,
|
|
5
6
|
} = {}) {
|
|
6
7
|
this.outlineOpen = false;
|
|
7
8
|
this.activeHeadingFrame = null;
|
|
@@ -9,16 +10,36 @@ export class OutlineController {
|
|
|
9
10
|
this.headings = [];
|
|
10
11
|
this.pinnedHeadingId = null;
|
|
11
12
|
this.onNavigateToHeading = onNavigateToHeading;
|
|
13
|
+
this.onWillOpen = onWillOpen;
|
|
12
14
|
this.panel = document.getElementById('outlinePanel');
|
|
13
15
|
this.navigation = document.getElementById('outlineNav');
|
|
14
16
|
this.previewContainer = document.getElementById('previewContainer');
|
|
15
17
|
this.toggleButton = document.getElementById('outlineToggle');
|
|
16
18
|
this.mobileBreakpointQuery = mobileBreakpointQuery;
|
|
17
19
|
this.handlePreviewScroll = () => this.scheduleActiveHeadingUpdate();
|
|
20
|
+
this.handleDocumentPointerDown = (event) => {
|
|
21
|
+
if (!this.outlineOpen) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const target = event.target;
|
|
26
|
+
if (
|
|
27
|
+
target instanceof Node
|
|
28
|
+
&& (
|
|
29
|
+
this.panel?.contains(target)
|
|
30
|
+
|| this.toggleButton?.contains(target)
|
|
31
|
+
)
|
|
32
|
+
) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.close();
|
|
37
|
+
};
|
|
18
38
|
}
|
|
19
39
|
|
|
20
40
|
initialize() {
|
|
21
41
|
this.toggleButton?.addEventListener('click', () => this.toggle());
|
|
42
|
+
document.addEventListener('pointerdown', this.handleDocumentPointerDown);
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
toggle() {
|
|
@@ -30,6 +51,10 @@ export class OutlineController {
|
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
setOpenState(nextState) {
|
|
54
|
+
if (nextState && !this.outlineOpen) {
|
|
55
|
+
this.onWillOpen?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
33
58
|
this.outlineOpen = nextState;
|
|
34
59
|
this.panel?.classList.toggle('hidden', !this.outlineOpen);
|
|
35
60
|
this.toggleButton?.classList.toggle('active', this.outlineOpen);
|
|
@@ -2265,6 +2265,10 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
|
|
|
2265
2265
|
|
|
2266
2266
|
/* Preview body (contains preview + outline side by side) */
|
|
2267
2267
|
.preview-body {
|
|
2268
|
+
--preview-floating-sidebar-gap: var(--space-3);
|
|
2269
|
+
--preview-floating-comments-width: min(300px, 34vw);
|
|
2270
|
+
--preview-floating-outline-width: 220px;
|
|
2271
|
+
--preview-content-max-width: 720px;
|
|
2268
2272
|
display: flex;
|
|
2269
2273
|
flex: 1;
|
|
2270
2274
|
overflow: hidden;
|
|
@@ -2298,6 +2302,12 @@ html[data-initial-file-requested="true"] .editor-page.hidden {
|
|
|
2298
2302
|
color: var(--color-text-muted);
|
|
2299
2303
|
}
|
|
2300
2304
|
|
|
2305
|
+
@media (min-width: 769px) {
|
|
2306
|
+
.preview-content {
|
|
2307
|
+
padding-bottom: calc(var(--space-12) + 96px);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2301
2311
|
.preview-content.is-excalidraw-file-preview {
|
|
2302
2312
|
display: flex;
|
|
2303
2313
|
width: 100%;
|
|
@@ -3670,14 +3680,45 @@ body.plantuml-maximized-open::before {
|
|
|
3670
3680
|
|
|
3671
3681
|
/* ===== BACKLINKS PANEL ===== */
|
|
3672
3682
|
|
|
3683
|
+
.backlinks-panel.hidden,
|
|
3684
|
+
.backlinks-panel-layer.hidden {
|
|
3685
|
+
display: none;
|
|
3686
|
+
}
|
|
3673
3687
|
.backlinks-panel {
|
|
3688
|
+
min-width: 0;
|
|
3689
|
+
}
|
|
3690
|
+
.backlinks-panel-layer {
|
|
3691
|
+
position: absolute;
|
|
3692
|
+
left: max(
|
|
3693
|
+
var(--space-4),
|
|
3694
|
+
calc((100% - min(var(--preview-content-max-width), 100%)) / 2)
|
|
3695
|
+
);
|
|
3696
|
+
bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
|
|
3697
|
+
width: min(320px, calc(min(var(--preview-content-max-width), 100%) - var(--space-8)));
|
|
3698
|
+
z-index: 14;
|
|
3699
|
+
pointer-events: none;
|
|
3700
|
+
}
|
|
3701
|
+
.backlinks-panel-dock {
|
|
3702
|
+
display: flex;
|
|
3703
|
+
flex-direction: column-reverse;
|
|
3704
|
+
border: 1px solid var(--color-divider);
|
|
3705
|
+
border-radius: 18px;
|
|
3706
|
+
background: color-mix(in srgb, var(--color-surface) 84%, var(--color-surface-offset) 16%);
|
|
3707
|
+
box-shadow: 0 14px 34px oklch(0 0 0 / 0.18);
|
|
3708
|
+
backdrop-filter: blur(16px);
|
|
3709
|
+
overflow: hidden;
|
|
3710
|
+
pointer-events: auto;
|
|
3711
|
+
transition: box-shadow 160ms ease, transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
|
3712
|
+
}
|
|
3713
|
+
.backlinks-panel-dock:hover {
|
|
3714
|
+
transform: translateY(-1px);
|
|
3715
|
+
box-shadow: 0 18px 40px oklch(0 0 0 / 0.2);
|
|
3716
|
+
}
|
|
3717
|
+
.backlinks-panel-inline {
|
|
3674
3718
|
border-top: 1px solid var(--color-divider);
|
|
3675
3719
|
margin-top: var(--space-6);
|
|
3676
3720
|
padding: 0 var(--space-4);
|
|
3677
3721
|
}
|
|
3678
|
-
.backlinks-panel.hidden {
|
|
3679
|
-
display: none;
|
|
3680
|
-
}
|
|
3681
3722
|
.backlinks-header {
|
|
3682
3723
|
display: flex;
|
|
3683
3724
|
align-items: center;
|
|
@@ -3693,6 +3734,10 @@ body.plantuml-maximized-open::before {
|
|
|
3693
3734
|
.backlinks-header:hover {
|
|
3694
3735
|
color: var(--color-text);
|
|
3695
3736
|
}
|
|
3737
|
+
.backlinks-panel-dock .backlinks-header {
|
|
3738
|
+
padding: 11px 14px;
|
|
3739
|
+
background: color-mix(in srgb, var(--color-surface-offset) 76%, var(--color-bg) 24%);
|
|
3740
|
+
}
|
|
3696
3741
|
.backlinks-header-empty {
|
|
3697
3742
|
cursor: default;
|
|
3698
3743
|
opacity: 0.5;
|
|
@@ -3709,15 +3754,20 @@ body.plantuml-maximized-open::before {
|
|
|
3709
3754
|
}
|
|
3710
3755
|
.backlinks-toggle {
|
|
3711
3756
|
flex: 1;
|
|
3757
|
+
min-width: 0;
|
|
3758
|
+
overflow: hidden;
|
|
3759
|
+
text-overflow: ellipsis;
|
|
3760
|
+
white-space: nowrap;
|
|
3712
3761
|
}
|
|
3713
3762
|
.backlinks-count {
|
|
3714
3763
|
font-size: var(--text-xs);
|
|
3715
3764
|
font-weight: 600;
|
|
3716
3765
|
color: var(--color-primary);
|
|
3717
|
-
background: oklch(from var(--color-primary) l c h / 0.
|
|
3766
|
+
background: oklch(from var(--color-primary) l c h / 0.14);
|
|
3718
3767
|
border-radius: var(--radius-full);
|
|
3719
|
-
padding: 1px
|
|
3720
|
-
min-width:
|
|
3768
|
+
padding: 1px 7px;
|
|
3769
|
+
min-width: 22px;
|
|
3770
|
+
text-align: center;
|
|
3721
3771
|
}
|
|
3722
3772
|
.backlinks-count:empty {
|
|
3723
3773
|
display: none;
|
|
@@ -3727,16 +3777,31 @@ body.plantuml-maximized-open::before {
|
|
|
3727
3777
|
max-height: 0;
|
|
3728
3778
|
transition: max-height 200ms ease;
|
|
3729
3779
|
}
|
|
3730
|
-
.backlinks-panel.expanded .backlinks-body {
|
|
3780
|
+
.backlinks-panel-inline.expanded .backlinks-body {
|
|
3731
3781
|
max-height: 600px;
|
|
3732
3782
|
overflow-y: auto;
|
|
3733
3783
|
}
|
|
3784
|
+
.backlinks-panel-dock .backlinks-body {
|
|
3785
|
+
background: color-mix(in srgb, var(--color-surface) 94%, var(--color-surface-offset) 6%);
|
|
3786
|
+
}
|
|
3787
|
+
.backlinks-panel-dock.expanded .backlinks-body {
|
|
3788
|
+
max-height: min(44vh, 400px);
|
|
3789
|
+
overflow-y: auto;
|
|
3790
|
+
border-bottom: 1px solid color-mix(in srgb, var(--color-divider) 78%, var(--color-primary) 22%);
|
|
3791
|
+
}
|
|
3792
|
+
.backlinks-panel-dock.expanded {
|
|
3793
|
+
border-color: color-mix(in srgb, var(--color-divider) 72%, var(--color-primary) 28%);
|
|
3794
|
+
background: color-mix(in srgb, var(--color-surface) 90%, var(--color-surface-offset) 10%);
|
|
3795
|
+
}
|
|
3734
3796
|
.backlinks-list {
|
|
3735
3797
|
padding-bottom: var(--space-4);
|
|
3736
3798
|
display: flex;
|
|
3737
3799
|
flex-direction: column;
|
|
3738
3800
|
gap: var(--space-2);
|
|
3739
3801
|
}
|
|
3802
|
+
.backlinks-panel-dock .backlinks-list {
|
|
3803
|
+
padding: 10px;
|
|
3804
|
+
}
|
|
3740
3805
|
.backlink-item {
|
|
3741
3806
|
display: block;
|
|
3742
3807
|
width: 100%;
|
|
@@ -3748,6 +3813,10 @@ body.plantuml-maximized-open::before {
|
|
|
3748
3813
|
cursor: pointer;
|
|
3749
3814
|
transition: border-color var(--transition-interactive), background var(--transition-interactive);
|
|
3750
3815
|
}
|
|
3816
|
+
.backlinks-panel-dock .backlink-item {
|
|
3817
|
+
border-radius: 12px;
|
|
3818
|
+
background: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-offset) 12%);
|
|
3819
|
+
}
|
|
3751
3820
|
.backlink-item:hover {
|
|
3752
3821
|
border-color: var(--color-primary);
|
|
3753
3822
|
background: oklch(from var(--color-primary) l c h / 0.04);
|
|
@@ -3789,6 +3858,12 @@ body.plantuml-maximized-open::before {
|
|
|
3789
3858
|
color: var(--color-text-faint);
|
|
3790
3859
|
}
|
|
3791
3860
|
|
|
3861
|
+
@media (min-width: 769px) {
|
|
3862
|
+
.backlinks-panel-inline {
|
|
3863
|
+
display: none !important;
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3792
3867
|
.outline-panel {
|
|
3793
3868
|
width: 220px;
|
|
3794
3869
|
flex-shrink: 0;
|
|
@@ -3889,6 +3964,9 @@ body.plantuml-maximized-open::before {
|
|
|
3889
3964
|
.editor-layout[data-view="preview"] .preview-pane {
|
|
3890
3965
|
flex: 1;
|
|
3891
3966
|
}
|
|
3967
|
+
.editor-layout[data-view="preview"] .preview-body {
|
|
3968
|
+
--preview-content-max-width: 1080px;
|
|
3969
|
+
}
|
|
3892
3970
|
.editor-layout[data-view="preview"] .preview-content {
|
|
3893
3971
|
max-width: 1080px;
|
|
3894
3972
|
}
|
|
@@ -3923,6 +4001,55 @@ body.plantuml-maximized-open::before {
|
|
|
3923
4001
|
.editor-layout[data-view="preview"] .preview-content p:has(> .plantuml-shell:only-child) {
|
|
3924
4002
|
max-width: none;
|
|
3925
4003
|
}
|
|
4004
|
+
@media (min-width: 769px) {
|
|
4005
|
+
.preview-body > .comments-drawer,
|
|
4006
|
+
.preview-body > .outline-panel {
|
|
4007
|
+
position: absolute;
|
|
4008
|
+
top: var(--preview-floating-sidebar-gap);
|
|
4009
|
+
right: var(--preview-floating-sidebar-gap);
|
|
4010
|
+
bottom: var(--preview-floating-sidebar-gap);
|
|
4011
|
+
z-index: 20;
|
|
4012
|
+
overflow: hidden;
|
|
4013
|
+
border: 1px solid var(--color-divider);
|
|
4014
|
+
border-radius: calc(var(--radius-lg) + 2px);
|
|
4015
|
+
box-shadow: var(--shadow-lg);
|
|
4016
|
+
backdrop-filter: blur(14px);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
.preview-body > .comments-drawer {
|
|
4020
|
+
width: var(--preview-floating-comments-width);
|
|
4021
|
+
min-width: 240px;
|
|
4022
|
+
border-left: 1px solid var(--color-divider);
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
.preview-body > .outline-panel {
|
|
4026
|
+
width: var(--preview-floating-outline-width);
|
|
4027
|
+
border-left: 1px solid var(--color-divider);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
.preview-body > .comments-drawer.hidden,
|
|
4031
|
+
.preview-body > .outline-panel.hidden {
|
|
4032
|
+
display: flex !important;
|
|
4033
|
+
opacity: 0;
|
|
4034
|
+
pointer-events: none;
|
|
4035
|
+
transform: translateX(12px);
|
|
4036
|
+
visibility: hidden;
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
.preview-body > .comments-drawer:not(.hidden),
|
|
4040
|
+
.preview-body > .outline-panel:not(.hidden) {
|
|
4041
|
+
opacity: 1;
|
|
4042
|
+
transform: translateX(0);
|
|
4043
|
+
visibility: visible;
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
.preview-body:has(> .outline-panel:not(.hidden)) > .comments-drawer:not(.hidden) {
|
|
4047
|
+
right: calc(
|
|
4048
|
+
var(--preview-floating-sidebar-gap) * 2
|
|
4049
|
+
+ var(--preview-floating-outline-width)
|
|
4050
|
+
);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
3926
4053
|
.editor-layout[data-view="editor"] .editor-pane {
|
|
3927
4054
|
flex: 1;
|
|
3928
4055
|
}
|
|
@@ -4022,6 +4149,10 @@ body.plantuml-maximized-open::before {
|
|
|
4022
4149
|
padding: var(--space-4);
|
|
4023
4150
|
}
|
|
4024
4151
|
|
|
4152
|
+
.backlinks-panel-layer {
|
|
4153
|
+
display: none !important;
|
|
4154
|
+
}
|
|
4155
|
+
|
|
4025
4156
|
.diff-toolbar {
|
|
4026
4157
|
flex-wrap: wrap;
|
|
4027
4158
|
padding: var(--space-2) var(--space-3);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export function createGitRequestError(statusCode, message) {
|
|
1
|
+
export function createGitRequestError(statusCode, message, requestCode = null) {
|
|
2
2
|
const error = new Error(message);
|
|
3
3
|
error.statusCode = statusCode;
|
|
4
|
+
if (requestCode) {
|
|
5
|
+
error.requestCode = requestCode;
|
|
6
|
+
}
|
|
4
7
|
return error;
|
|
5
8
|
}
|
|
@@ -6,6 +6,7 @@ import { parseNameStatusOutput } from './parsers.js';
|
|
|
6
6
|
import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
|
|
7
7
|
import { GitStatusService } from './status-service.js';
|
|
8
8
|
import { GitUntrackedFileService } from './untracked-files.js';
|
|
9
|
+
import { PullBackupStore } from '../persistence/pull-backup-store.js';
|
|
9
10
|
|
|
10
11
|
export class GitService {
|
|
11
12
|
constructor({
|
|
@@ -36,6 +37,7 @@ export class GitService {
|
|
|
36
37
|
statusService: this.statusService,
|
|
37
38
|
untrackedFileService: this.untrackedFileService,
|
|
38
39
|
});
|
|
40
|
+
this.pullBackupStore = new PullBackupStore({ vaultDir });
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async isGitRepo() {
|
|
@@ -55,6 +57,14 @@ export class GitService {
|
|
|
55
57
|
return this.statusService.getStatus(options);
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
async listPullBackups() {
|
|
61
|
+
if (!(await this.isGitRepo())) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return this.pullBackupStore.listBackups();
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
async stageFile(path) {
|
|
59
69
|
const normalizedPath = normalizeRelativeGitPath(path);
|
|
60
70
|
await this.commandRunner.execGit(['add', '-A', '--', normalizedPath]);
|
|
@@ -131,7 +141,45 @@ export class GitService {
|
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
const beforeRef = await this.getHeadRef();
|
|
134
|
-
|
|
144
|
+
await this.fetchUpstream(status.branch.upstream);
|
|
145
|
+
|
|
146
|
+
const targetRef = await this.resolveRef(status.branch.upstream);
|
|
147
|
+
if (!targetRef) {
|
|
148
|
+
throw createGitRequestError(409, 'Unable to resolve upstream branch for pull');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!(await this.canFastForwardTo(targetRef))) {
|
|
152
|
+
throw createGitRequestError(
|
|
153
|
+
409,
|
|
154
|
+
'Cannot pull because local and remote commits have diverged; fast-forward only pull is not possible.',
|
|
155
|
+
'pull_diverged_ff_only',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dirtyEntries = this.collectDirtyEntries(status);
|
|
160
|
+
const upstreamWorkspaceChange = await this.createWorkspaceChangeFromRefs(beforeRef, targetRef);
|
|
161
|
+
const overlappingEntries = this.findOverlappingDirtyEntries({
|
|
162
|
+
dirtyEntries,
|
|
163
|
+
workspaceChange: upstreamWorkspaceChange,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let pullBackup = null;
|
|
167
|
+
if (overlappingEntries.length > 0) {
|
|
168
|
+
pullBackup = await this.backupAndClearOverlappingEntries({
|
|
169
|
+
beforeRef,
|
|
170
|
+
branchName: status.branch?.name ?? null,
|
|
171
|
+
entries: overlappingEntries,
|
|
172
|
+
targetRef,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let output;
|
|
177
|
+
try {
|
|
178
|
+
output = await this.commandRunner.execGit(['pull', '--ff-only', '--autostash']);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw await this.classifyPullError(error);
|
|
181
|
+
}
|
|
182
|
+
|
|
135
183
|
const afterRef = await this.getHeadRef();
|
|
136
184
|
this.invalidateStatusCache();
|
|
137
185
|
return {
|
|
@@ -139,6 +187,7 @@ export class GitService {
|
|
|
139
187
|
beforeRef,
|
|
140
188
|
ok: true,
|
|
141
189
|
output: output.trim(),
|
|
190
|
+
pullBackup,
|
|
142
191
|
workspaceChange: await this.createWorkspaceChangeFromRefs(beforeRef, afterRef),
|
|
143
192
|
};
|
|
144
193
|
}
|
|
@@ -202,6 +251,171 @@ export class GitService {
|
|
|
202
251
|
}
|
|
203
252
|
}
|
|
204
253
|
|
|
254
|
+
async resolveRef(ref) {
|
|
255
|
+
try {
|
|
256
|
+
const output = await this.commandRunner.execGit(['rev-parse', ref]);
|
|
257
|
+
return output.trim() || null;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async fetchUpstream(upstreamRef) {
|
|
264
|
+
const remoteName = String(upstreamRef ?? '').split('/')[0] || null;
|
|
265
|
+
await this.commandRunner.execGit(remoteName
|
|
266
|
+
? ['fetch', '--prune', remoteName]
|
|
267
|
+
: ['fetch', '--prune']);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async canFastForwardTo(targetRef) {
|
|
271
|
+
const headRef = await this.getHeadRef();
|
|
272
|
+
if (!headRef || !targetRef || headRef === targetRef) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await this.commandRunner.execGit(['merge-base', '--is-ancestor', headRef, targetRef]);
|
|
278
|
+
return true;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error?.code === 1) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
collectDirtyEntries(status = {}) {
|
|
288
|
+
const entries = new Map();
|
|
289
|
+
for (const section of status.sections ?? []) {
|
|
290
|
+
for (const file of section.files ?? []) {
|
|
291
|
+
if (!file?.path) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const existing = entries.get(file.path) ?? {
|
|
296
|
+
hasStagedChanges: false,
|
|
297
|
+
hasWorkingTreeChanges: false,
|
|
298
|
+
hasTrackedChanges: false,
|
|
299
|
+
isUntracked: false,
|
|
300
|
+
oldPath: null,
|
|
301
|
+
path: file.path,
|
|
302
|
+
touchPaths: new Set(),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
existing.isUntracked = existing.isUntracked || file.scope === 'untracked';
|
|
306
|
+
existing.hasStagedChanges = existing.hasStagedChanges || file.scope === 'staged';
|
|
307
|
+
existing.hasWorkingTreeChanges = existing.hasWorkingTreeChanges || file.scope === 'working-tree';
|
|
308
|
+
existing.hasTrackedChanges = existing.hasTrackedChanges || file.scope === 'staged' || file.scope === 'working-tree';
|
|
309
|
+
if (!existing.oldPath && file.oldPath) {
|
|
310
|
+
existing.oldPath = file.oldPath;
|
|
311
|
+
}
|
|
312
|
+
existing.touchPaths.add(file.path);
|
|
313
|
+
if (file.oldPath) {
|
|
314
|
+
existing.touchPaths.add(file.oldPath);
|
|
315
|
+
}
|
|
316
|
+
entries.set(file.path, existing);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return Array.from(entries.values());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
findOverlappingDirtyEntries({ dirtyEntries = [], workspaceChange = createEmptyWorkspaceChange() } = {}) {
|
|
324
|
+
const upstreamTouchedPaths = new Set([
|
|
325
|
+
...(workspaceChange.changedPaths ?? []),
|
|
326
|
+
...(workspaceChange.deletedPaths ?? []),
|
|
327
|
+
...((workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry.oldPath, entry.newPath])),
|
|
328
|
+
].filter(Boolean));
|
|
329
|
+
|
|
330
|
+
return dirtyEntries.filter((entry) => Array.from(entry.touchPaths).some((path) => upstreamTouchedPaths.has(path)));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async createPatchForEntry(entry, { cached = false } = {}) {
|
|
334
|
+
const pathspecs = Array.from(entry?.touchPaths ?? []).filter(Boolean);
|
|
335
|
+
if (pathspecs.length === 0) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const output = await this.commandRunner.execGit([
|
|
340
|
+
'diff',
|
|
341
|
+
'--binary',
|
|
342
|
+
'--find-renames',
|
|
343
|
+
...(cached ? ['--cached'] : []),
|
|
344
|
+
'--',
|
|
345
|
+
...pathspecs,
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
return output || null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async backupAndClearOverlappingEntries({
|
|
352
|
+
beforeRef,
|
|
353
|
+
branchName = null,
|
|
354
|
+
entries = [],
|
|
355
|
+
targetRef = null,
|
|
356
|
+
} = {}) {
|
|
357
|
+
const backupEntries = await Promise.all(entries.map(async (entry) => ({
|
|
358
|
+
...entry,
|
|
359
|
+
stagedPatchContent: entry.hasStagedChanges
|
|
360
|
+
? await this.createPatchForEntry(entry, { cached: true })
|
|
361
|
+
: null,
|
|
362
|
+
worktreePatchContent: entry.hasWorkingTreeChanges
|
|
363
|
+
? await this.createPatchForEntry(entry, { cached: false })
|
|
364
|
+
: null,
|
|
365
|
+
})));
|
|
366
|
+
|
|
367
|
+
const pullBackup = await this.pullBackupStore.createBackup({
|
|
368
|
+
branch: branchName,
|
|
369
|
+
entries: backupEntries,
|
|
370
|
+
headRef: beforeRef,
|
|
371
|
+
targetRef,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
for (const entry of backupEntries) {
|
|
375
|
+
if (entry.isUntracked && !entry.hasTrackedChanges) {
|
|
376
|
+
await this.commandRunner.execGit(['clean', '-f', '--', entry.path]);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await this.restorePathToHead(entry.path);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return pullBackup;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async restorePathToHead(path) {
|
|
387
|
+
const normalizedPath = normalizeRelativeGitPath(path);
|
|
388
|
+
const sourceRef = 'HEAD';
|
|
389
|
+
const existsOnSource = await this.pathExistsAtRef(sourceRef, normalizedPath);
|
|
390
|
+
|
|
391
|
+
if (existsOnSource) {
|
|
392
|
+
await this.commandRunner.execGit(['restore', '--source', sourceRef, '--staged', '--worktree', '--', normalizedPath]);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await this.commandRunner.execGit(['rm', '-f', '--ignore-unmatch', '--', normalizedPath]);
|
|
397
|
+
await this.commandRunner.execGit(['clean', '-f', '--', normalizedPath]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async hasConflictedStatus() {
|
|
401
|
+
const status = await this.getStatus({ force: true });
|
|
402
|
+
return (status.sections ?? [])
|
|
403
|
+
.flatMap((section) => section.files ?? [])
|
|
404
|
+
.some((file) => file?.status === 'conflicted');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async classifyPullError(error) {
|
|
408
|
+
if (await this.hasConflictedStatus()) {
|
|
409
|
+
return createGitRequestError(
|
|
410
|
+
409,
|
|
411
|
+
'Pull applied remote updates, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.',
|
|
412
|
+
'pull_conflicted_after_autostash',
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return error;
|
|
417
|
+
}
|
|
418
|
+
|
|
205
419
|
async createWorkspaceChangeFromRefs(beforeRef, afterRef) {
|
|
206
420
|
if (!afterRef || beforeRef === afterRef) {
|
|
207
421
|
return createEmptyWorkspaceChange();
|