Skip to content

Commit 15bf4f9

Browse files
feat(Wind): Add History, Label, Model, and TextModelResolver services
Implement four new Effect-TS service layers in Wind that connect to Mountain via Tauri IPC: - **HistoryService**: Manages editor navigation stack for back/forward traversal. IPC: history:goBack, goForward, canGoBack, canGoForward, push, clear, getStack. - **LabelService**: Resolves human-readable display labels for URIs, workspace roots, and filenames (used in explorer, tabs, breadcrumbs). IPC: label:getUri, getWorkspace, getBase. - **ModelService**: Lightweight in-memory text model registry. Maintains open documents with content/version/languageId. IPC: model:open, close, get, getAll, updateContent. - **TextModelResolverService**: Resolves URIs to text model references with reference-counting dispose pattern (VS Code IReference<ITextModel>). Delegates to ModelService with ref counting. Each service includes interface, stub implementation, live IPC layer, mock layer, Effect Context tag, and problem types following Wind's established pattern. Also refactor TauriIPC invoke to use unified "mountain_ipc_invoke" command with {method, params} structure, and update all layer composition files to use Layer.provideMerge() chaining.
1 parent 1b4d4b7 commit 15bf4f9

32 files changed

Lines changed: 857 additions & 123 deletions

Source/Effect/History/History.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type { HistoryProblem } from "./Type/HistoryProblem.js";
2+
export type { HistoryService } from "./Interface/HistoryService.js";
3+
export { HistoryServiceTag, History } from "./Tag/HistoryServiceTag.js";
4+
export { StubHistoryService } from "./Implementation/HistoryStub.js";
5+
export { default as LiveHistoryServiceLayer } from "./Live.js";
6+
export { default as MockHistoryServiceLayer } from "./Mock.js";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Effect } from "effect";
2+
3+
import type { HistoryService } from "../Interface/HistoryService.js";
4+
5+
export const StubHistoryService: HistoryService = {
6+
GoBack: () => Effect.void,
7+
GoForward: () => Effect.void,
8+
CanGoBack: () => Effect.succeed(false),
9+
CanGoForward: () => Effect.succeed(false),
10+
Push: (_uri) => Effect.void,
11+
Clear: () => Effect.void,
12+
GetStack: () => Effect.succeed([]),
13+
};
14+
15+
export default StubHistoryService;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Effect } from "effect";
2+
3+
import type { HistoryProblem } from "../Type/HistoryProblem.js";
4+
5+
/**
6+
* Navigation history service interface.
7+
* Microsoft VSCode Reference: IHistoryService from vs/workbench/services/history/browser/historyService.ts
8+
*
9+
* Tracks the editor navigation stack and allows back/forward traversal
10+
* across recently visited files and cursor positions.
11+
*/
12+
export interface HistoryService {
13+
/** Navigate to the previous location in the history stack. */
14+
readonly GoBack: () => Effect.Effect<void, HistoryProblem>;
15+
16+
/** Navigate to the next location in the history stack. */
17+
readonly GoForward: () => Effect.Effect<void, HistoryProblem>;
18+
19+
/** Returns true if there is a previous location to navigate to. */
20+
readonly CanGoBack: () => Effect.Effect<boolean, HistoryProblem>;
21+
22+
/** Returns true if there is a next location to navigate to. */
23+
readonly CanGoForward: () => Effect.Effect<boolean, HistoryProblem>;
24+
25+
/** Push a new URI onto the navigation stack, clearing forward history. */
26+
readonly Push: (uri: string) => Effect.Effect<void, HistoryProblem>;
27+
28+
/** Clear the entire navigation history stack. */
29+
readonly Clear: () => Effect.Effect<void, HistoryProblem>;
30+
31+
/** Return the full history stack as a list of URIs (oldest first). */
32+
readonly GetStack: () => Effect.Effect<readonly string[], HistoryProblem>;
33+
}

Source/Effect/History/Live.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* @module Effect/History/Live
3+
* @description
4+
* Live implementation of HistoryService backed by Mountain's navigation
5+
* history store via Tauri IPC. Drives back/forward navigation in the editor.
6+
*
7+
* IPC channels (WindServiceHandlers.rs):
8+
* history:goBack → navigate to previous location
9+
* history:goForward → navigate to next location
10+
* history:canGoBack → whether back navigation is available
11+
* history:canGoForward → whether forward navigation is available
12+
* history:push → push a URI onto the stack
13+
* history:clear → clear the navigation stack
14+
* history:getStack → return the full history stack
15+
*/
16+
17+
import { Effect, Layer } from "effect";
18+
19+
import { IPC } from "../IPC.js";
20+
import type { HistoryService } from "./Interface/HistoryService.js";
21+
import { HistoryServiceTag } from "./Tag/HistoryServiceTag.js";
22+
import type { HistoryProblem } from "./Type/HistoryProblem.js";
23+
24+
const MakeHistoryProblem = (error: unknown): HistoryProblem => ({
25+
_tag: "HistoryOperationFailed",
26+
error: error instanceof Error ? error : new Error(String(error)),
27+
});
28+
29+
export const LiveHistoryServiceLayer = Layer.effect(
30+
HistoryServiceTag,
31+
Effect.gen(function* () {
32+
const IPCService = yield* IPC;
33+
34+
const Service: HistoryService = {
35+
GoBack: () =>
36+
IPCService.invoke("history:goBack")([]).pipe(
37+
Effect.map(() => undefined as void),
38+
Effect.mapError(MakeHistoryProblem),
39+
),
40+
41+
GoForward: () =>
42+
IPCService.invoke("history:goForward")([]).pipe(
43+
Effect.map(() => undefined as void),
44+
Effect.mapError(MakeHistoryProblem),
45+
),
46+
47+
CanGoBack: () =>
48+
IPCService.invoke("history:canGoBack")([]).pipe(
49+
Effect.map((Result) => Result === true),
50+
Effect.mapError(MakeHistoryProblem),
51+
),
52+
53+
CanGoForward: () =>
54+
IPCService.invoke("history:canGoForward")([]).pipe(
55+
Effect.map((Result) => Result === true),
56+
Effect.mapError(MakeHistoryProblem),
57+
),
58+
59+
Push: (uri) =>
60+
IPCService.invoke("history:push")([uri]).pipe(
61+
Effect.map(() => undefined as void),
62+
Effect.mapError(MakeHistoryProblem),
63+
),
64+
65+
Clear: () =>
66+
IPCService.invoke("history:clear")([]).pipe(
67+
Effect.map(() => undefined as void),
68+
Effect.mapError(MakeHistoryProblem),
69+
),
70+
71+
GetStack: () =>
72+
IPCService.invoke("history:getStack")([]).pipe(
73+
Effect.map((Result) =>
74+
Array.isArray(Result)
75+
? (Result as readonly string[])
76+
: [],
77+
),
78+
Effect.mapError(MakeHistoryProblem),
79+
),
80+
};
81+
82+
return Service;
83+
}),
84+
);
85+
86+
export default LiveHistoryServiceLayer;

Source/Effect/History/Mock.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Layer } from "effect";
2+
3+
import { StubHistoryService } from "./Implementation/HistoryStub.js";
4+
import { HistoryServiceTag } from "./Tag/HistoryServiceTag.js";
5+
6+
export const MockHistoryServiceLayer = Layer.succeed(
7+
HistoryServiceTag,
8+
StubHistoryService,
9+
);
10+
11+
export default MockHistoryServiceLayer;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Context } from "effect";
2+
3+
import type { HistoryService } from "../Interface/HistoryService.js";
4+
5+
export class HistoryServiceTag extends Context.Tag(
6+
"Application/HistoryService",
7+
)<HistoryServiceTag, HistoryService>() {}
8+
9+
export const History = HistoryServiceTag;
10+
11+
export default HistoryServiceTag;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type HistoryProblem =
2+
| { readonly _tag: "HistoryOperationFailed"; readonly error: Error }
3+
| { readonly _tag: "HistoryStackEmpty" };

Source/Effect/IPC/Implementation/TauriIPC.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,21 @@ export const TauriIPCLive = Effect.gen(function* () {
4848
invoke: (channel: string) => (args: ReadonlyArray<unknown>) =>
4949
Effect.tryPromise({
5050
try: () => {
51-
const invokeArgs: InvokeArgs | undefined =
52-
args.length === 1
53-
? (args[0] as InvokeArgs)
54-
: (args as unknown as InvokeArgs);
55-
return tauriInvoke(channel, invokeArgs);
51+
// All Wind IPC calls route through the single MountainIPCInvoke
52+
// Tauri command (registered as "mountain_ipc_invoke").
53+
// Mountain receives: method = channel name, params = args array.
54+
// Send as array so Mountain can always destructure positionally.
55+
// Single-element arrays are preserved; empty stays [].
56+
const params: unknown =
57+
args.length === 0
58+
? []
59+
: args.length === 1
60+
? args[0]
61+
: Array.from(args);
62+
return tauriInvoke("mountain_ipc_invoke", {
63+
method: channel,
64+
params,
65+
});
5666
},
5767
catch: (error) => CreateIPCInvokeError(channel, error),
5868
}),
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Effect } from "effect";
2+
3+
import type { LabelService } from "../Interface/LabelService.js";
4+
5+
export const StubLabelService: LabelService = {
6+
GetUriLabel: (uri, _options) => Effect.succeed(uri),
7+
GetWorkspaceLabel: () => Effect.succeed(""),
8+
GetBaseLabel: (uri) => Effect.succeed(uri.split("/").pop() ?? uri),
9+
};
10+
11+
export default StubLabelService;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Effect } from "effect";
2+
3+
import type { LabelProblem } from "../Type/LabelProblem.js";
4+
5+
/**
6+
* Label service interface.
7+
* Microsoft VSCode Reference: ILabelService from vs/platform/label/common/label.ts
8+
*
9+
* Resolves human-readable display labels for URIs, workspace folders,
10+
* and file paths. Used in the explorer tree, tabs, and breadcrumbs.
11+
*/
12+
export interface LabelService {
13+
/**
14+
* Resolve a display label for a URI.
15+
* @param uri - The URI to label (e.g. "file:///home/user/project/src/foo.ts")
16+
* @param options.relative - When true, return path relative to workspace root
17+
*/
18+
readonly GetUriLabel: (
19+
uri: string,
20+
options?: { readonly relative?: boolean },
21+
) => Effect.Effect<string, LabelProblem>;
22+
23+
/**
24+
* Return a human-readable label for the current workspace root.
25+
* Returns the workspace folder name, or empty string if no workspace is open.
26+
*/
27+
readonly GetWorkspaceLabel: () => Effect.Effect<string, LabelProblem>;
28+
29+
/**
30+
* Return only the base name (filename + extension) of a URI.
31+
*/
32+
readonly GetBaseLabel: (uri: string) => Effect.Effect<string, LabelProblem>;
33+
}

0 commit comments

Comments
 (0)