Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/source-control-refactor/phase-1-viewmodel-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Phase 1 — Source Control ViewModel Foundation

## Goal

建立 Source Control UI 與 Sync domain 之間的 ViewModel layer。

本階段不修改同步行為,只整理資料流。

## Scope

- ChangeRepository
- SourceControlFilter
- SourceControlViewModel
- ChangeTreeBuilder

## Architecture

```
UI
|
SourceControlViewModel
|
SyncManager
```

## Modules

```
src/logic/source-control/
├── ChangeRepository.ts
├── SourceControlFilter.ts
├── SourceControlViewModel.ts
└── ChangeTreeBuilder.ts
```

## Filter

Supported:

- all
- changes
- ready-to-push
- remote-changes
- conflicts
- synced

## Rules

UI components consume ViewModel only.

No direct SyncManager access from UI.

## Tests

- ChangeRepository
- SourceControlViewModel
- ChangeTreeBuilder

Cases:

- local changes
- remote changes
- conflicts
- ready to push
- rename keeps ChangeId
72 changes: 72 additions & 0 deletions docs/source-control-refactor/phase-2-action-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Phase 2 — Sync Action Unification

## Goal

統一 Source Control、Context Menu、Single File 操作的 pipeline。

## Architecture

```
User Action
|
SourceControlActionService
|
SyncPlan
|
SyncExecutor
|
Git Provider
```

## New Module

```
src/logic/source-control/
└── SourceControlActionService.ts
```

## Actions

- Push
- Pull
- Delete Remote
- Delete Local
- Resolve Conflict

## Rules

ActionService:

DO:
- convert user intent to SyncPlan

DO NOT:
- execute git operation
- classify changes

## Flows

Single file:

```
changeId
-> ActionService
-> SyncPlan
-> Executor
```

Batch:

```
changeIds
-> ActionService
-> SyncPlan
```

## Tests

- single push
- batch push
- pull
- conflict resolution
- invalid ChangeId
82 changes: 82 additions & 0 deletions docs/source-control-refactor/phase-3-source-control-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Phase 3 — Source Control UI

## Goal

建立 VS Code style Source Control workflow。

## Layout

```
SourceControlView
|
+ Header
+ Filter
+ ChangeTree
+ DiffPanel
```

## Sections

- READY TO PUSH
- CHANGES
- REMOTE CHANGES
- CONFLICTS
- SYNCED

## Filter

```
All
Changes
Ready to Push
Remote Changes
Conflicts
Synced
```

## Tree View

Example:

```
▼ notes
M daily.md
A idea.md

▼ projects
! settings.md
```

## Components

```
SourceControlView
SourceControlHeader
FilterMenu
ChangeTree
ChangeItem
ChangeSection
PushButton
OperationIndicator
```

## Responsive

Desktop:
- Tree + Diff

Mobile:
- List + Detail

## Tests

- SourceControlView
- ChangeTree
- FilterMenu

Cases:

- filter switching
- selection
- push action
- operation status
58 changes: 58 additions & 0 deletions docs/source-control-refactor/phase-4-legacy-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Phase 4 — Legacy Cleanup

## Goal

移除舊 Source Control orchestration,保留同步核心能力。

## Remove

- old status mapping
- duplicated action handling
- legacy SyncStatusView logic

## Final Architecture

```
UI
|
ViewModel
|
ActionService
|
SyncPlan
|
Executor
|
Provider
```

## SyncManager

Before:

- UI state
- classification
- execution

After:

- sync facade

## Test Cleanup

Remove:

- duplicated implementation tests

Keep:

- sync integration tests
- provider tests
- conflict tests

## Acceptance

- UI has no sync logic
- no duplicate action pipeline
- existing behavior preserved
- architecture docs updated
37 changes: 37 additions & 0 deletions src/logic/source-control/ChangeRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ChangeId, SyncChange } from './types';

/**
* Read-side lookup for the current set of pending `SyncChange`s. Holds no
* sync/business logic of its own — it's populated wholesale (`replace`) by
* whatever assembles `SyncChange[]` from the sync domain, and exists purely
* to give the ViewModel and UI O(1) lookup by id or path instead of scanning
* an array.
*/
export class ChangeRepository {
private changes: SyncChange[] = [];
private readonly byId = new Map<ChangeId, SyncChange>();
private readonly byPath = new Map<string, SyncChange>();

/** Replaces the full change set, e.g. after a status refresh. */
replace(changes: readonly SyncChange[]): void {
this.changes = [...changes];
this.byId.clear();
this.byPath.clear();
for (const change of this.changes) {
this.byId.set(change.id, change);
this.byPath.set(change.path, change);
}
}

getAll(): SyncChange[] {
return [...this.changes];
}

getById(id: ChangeId): SyncChange | undefined {
return this.byId.get(id);
}

getByPath(path: string): SyncChange | undefined {
return this.byPath.get(path);
}
}
68 changes: 68 additions & 0 deletions src/logic/source-control/ChangeTreeBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { ChangeId, SyncChange, SyncChangeKind } from './types';

export interface ChangeTreeFileNode {
type: 'file';
id: ChangeId;
name: string;
path: string;
previousPath?: string;
kind: SyncChangeKind;
}

export interface ChangeTreeFolderNode {
type: 'folder';
name: string;
path: string;
children: ChangeTreeNode[];
}

export type ChangeTreeNode = ChangeTreeFileNode | ChangeTreeFolderNode;

/**
* Turns a flat `SyncChange[]` into a folder/file tree for rendering.
* A renamed/moved file is placed at its *current* path — `previousPath`
* travels with the file node purely for display (e.g. "old → new"), it does
* not create a second tree entry.
*/
export class ChangeTreeBuilder {
build(changes: readonly SyncChange[]): ChangeTreeNode[] {
const root: ChangeTreeFolderNode = { type: 'folder', name: '', path: '', children: [] };
for (const change of changes) {
this.insert(root, change);
}
return root.children;
}

private insert(root: ChangeTreeFolderNode, change: SyncChange): void {
const segments = change.path.split('/').filter(Boolean);
const fileName = segments.pop();
if (!fileName) return;

let folder = root;
let accumulatedPath = '';
for (const segment of segments) {
accumulatedPath = accumulatedPath ? `${accumulatedPath}/${segment}` : segment;
folder = this.getOrCreateFolder(folder, segment, accumulatedPath);
}

folder.children.push({
type: 'file',
id: change.id,
name: fileName,
path: change.path,
previousPath: change.previousPath,
kind: change.kind,
});
}

private getOrCreateFolder(parent: ChangeTreeFolderNode, name: string, path: string): ChangeTreeFolderNode {
const existing = parent.children.find(
(node): node is ChangeTreeFolderNode => node.type === 'folder' && node.name === name,
);
if (existing) return existing;

const created: ChangeTreeFolderNode = { type: 'folder', name, path, children: [] };
parent.children.push(created);
return created;
}
}
Loading
Loading