Skip to content

Commit b4358aa

Browse files
feat(Sky): Add land-native search provider and global exception cap
Add a search result provider that routes VS Code's Search viewlet queries through Mountain's `search:findFiles` / `search:findInFiles` handlers. The stock web's `RemoteSearchService` relies on File System Access API which returns `undefined` for Land's Mountain-based filesystem, causing silent zero-result returns. The new provider registers for both `file` (0) and `text` (1) search types, translating Mountain hits into the `IFileMatch` shape the workbench expects. Also add a global exception cap to PostHogBridge to prevent flooding posthog-js's CDN limit. The CDN enforces a hard 10 events/10s cap on `$exception` regardless of signature — a diverse-error boot could blow through this without a global counter. The cap is configurable via `LAND_POSTHOG_SKY_EXCEPTION_GLOBAL_LIMIT` (defaults to 7) and reports global drop metrics in the throttle summary.
1 parent ea2932a commit b4358aa

2 files changed

Lines changed: 228 additions & 1 deletion

File tree

Source/Function/SkyBridge.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,20 @@ interface CelCommandRegistry {
157157
handler: (...args: unknown[]) => unknown,
158158
): { dispose(): void };
159159
}
160+
interface CelSearchService {
161+
// `SearchProviderType`: file=0, text=1, aiText=2. Schema is the URI
162+
// scheme the provider answers for - "file" for local workspace content.
163+
registerSearchResultProvider(
164+
scheme: string,
165+
type: number,
166+
provider: unknown,
167+
): { dispose(): void };
168+
}
160169
interface CelServices {
161170
Statusbar: CelStatusbarService;
162171
Commands: CelCommandService;
163172
CommandRegistry: CelCommandRegistry;
173+
Search: CelSearchService;
164174
}
165175

166176
function GetServices(): CelServices | null {
@@ -663,6 +673,183 @@ export async function InstallSkyBridge(): Promise<void> {
663673
}
664674
});
665675

676+
// ---- Search result provider (Land-native) ----
677+
//
678+
// Stock VS Code web's `RemoteSearchService` constructs a
679+
// `LocalFileSearchWorkerClient` which calls
680+
// `HTMLFileSystemProvider.getHandle(folderUri)` to obtain a File System
681+
// Access API directory handle. Land's filesystem goes through Mountain
682+
// over Tauri IPC, not the browser's FSA API, so the handle resolve
683+
// returns `undefined` and the search viewlet silently returns zero
684+
// results. The fix: register a provider that routes to Mountain's
685+
// existing `search:findFiles` / `search:findInFiles` handlers via
686+
// `MountainIPCInvoke`. Registered for the `file` scheme under both
687+
// SearchProviderType.file (0) and SearchProviderType.text (1) so both
688+
// the Search viewlet text queries and file-name filter hit it.
689+
//
690+
// Registration is best-effort - if `__CEL_SERVICES__.Search` isn't
691+
// populated yet (workbench still booting), wait for the
692+
// `cel:workbench-ready` event fired by ExposeWorkbenchAccessor.
693+
const RegisterLandSearchProvider = () => {
694+
const Services = GetServices();
695+
if (!Services?.Search?.registerSearchResultProvider) return false;
696+
697+
// Extract the single-folder root URI from a query - Mountain's
698+
// search handlers take the active workspace folder, not a set.
699+
// Multi-root queries fan out over each folder; first one wins for
700+
// now (Land's scanner is single-root in the debug profile).
701+
const FolderFromQuery = (Query: any): string | null => {
702+
const Folder =
703+
Query?.folderQueries?.[0]?.folder ?? Query?.folder ?? null;
704+
if (!Folder) return null;
705+
if (typeof Folder === "string") return Folder;
706+
const Path = Folder?.fsPath ?? Folder?.path ?? "";
707+
return Path || null;
708+
};
709+
710+
// Translate a raw Mountain hit into the `IFileMatch` shape the
711+
// workbench renderer expects. `resource` must carry `$mid:1`
712+
// so VS Code's `URI.revive()` path restores it.
713+
const MatchFromHit = (Hit: any) => {
714+
const Raw = String(Hit?.uri ?? "");
715+
const OsPath = Raw.replace(/^file:\/\//, "");
716+
const Line = Number(Hit?.lineNumber ?? 1);
717+
const Preview = String(Hit?.preview ?? "");
718+
return {
719+
resource: { $mid: 1, path: OsPath, scheme: "file" },
720+
results: [
721+
{
722+
preview: { text: Preview, matches: [] },
723+
ranges: [
724+
{
725+
startLineNumber: Line,
726+
startColumn: 1,
727+
endLineNumber: Line,
728+
endColumn: Math.max(1, Preview.length + 1),
729+
},
730+
],
731+
},
732+
],
733+
};
734+
};
735+
736+
const Provider = {
737+
getAIName: async () => undefined,
738+
textSearch: async (
739+
Query: any,
740+
OnProgress?: (Item: unknown) => void,
741+
_Token?: unknown,
742+
) => {
743+
const Pattern = String(Query?.contentPattern?.pattern ?? "");
744+
if (!Pattern) {
745+
return { results: [], messages: [], limitHit: false };
746+
}
747+
const IsRegex = Boolean(Query?.contentPattern?.isRegExp);
748+
const IsCaseSensitive = Boolean(
749+
Query?.contentPattern?.isCaseSensitive,
750+
);
751+
const IsWordMatch = Boolean(Query?.contentPattern?.isWordMatch);
752+
const Include =
753+
Object.keys(Query?.includePattern ?? {})[0] ?? "**";
754+
const Exclude = Object.keys(Query?.excludePattern ?? {})[0] ?? "";
755+
const MaxResults = Number(Query?.maxResults ?? 1000);
756+
try {
757+
const Raw = (await invoke("MountainIPCInvoke", {
758+
method: "search:findInFiles",
759+
params: [
760+
Pattern,
761+
IsRegex,
762+
IsCaseSensitive,
763+
IsWordMatch,
764+
Include,
765+
Exclude,
766+
MaxResults,
767+
],
768+
})) as any[];
769+
const Results: any[] = [];
770+
for (const Hit of Raw ?? []) {
771+
const Match = MatchFromHit(Hit);
772+
OnProgress?.(Match);
773+
Results.push(Match);
774+
}
775+
return {
776+
results: Results,
777+
messages: [],
778+
limitHit: Results.length >= MaxResults,
779+
};
780+
} catch (Error) {
781+
console.warn("[SkyBridge] textSearch failed", Error);
782+
return { results: [], messages: [], limitHit: false };
783+
}
784+
},
785+
fileSearch: async (Query: any, _Token?: unknown) => {
786+
// IFileQuery.filePattern is the user's typed filename
787+
// fragment (e.g. "set" matches "settings.ts"). Mountain's
788+
// `search:findFiles` takes a glob, so wrap the fragment
789+
// as `**/<pattern>*` to get prefix-substring matching -
790+
// a close approximation to VS Code's fuzzy file matcher.
791+
const Raw = String(Query?.filePattern ?? "").trim();
792+
const FolderRoot = FolderFromQuery(Query);
793+
const Glob = Raw
794+
? `**/*${Raw}*`
795+
: Object.keys(Query?.includePattern ?? {})[0] ?? "**";
796+
const MaxResults = Number(Query?.maxResults ?? 500);
797+
try {
798+
const Files = (await invoke("MountainIPCInvoke", {
799+
method: "search:findFiles",
800+
params: [Glob, MaxResults],
801+
})) as string[];
802+
const Results = (Files ?? []).map((Uri) => ({
803+
resource: {
804+
$mid: 1,
805+
path: String(Uri).replace(/^file:\/\//, ""),
806+
scheme: "file",
807+
},
808+
}));
809+
// Suppress unused warning - FolderRoot would be used
810+
// by a multi-folder fan-out that we don't need yet.
811+
void FolderRoot;
812+
return {
813+
results: Results,
814+
messages: [],
815+
limitHit: Results.length >= MaxResults,
816+
};
817+
} catch (Error) {
818+
console.warn("[SkyBridge] fileSearch failed", Error);
819+
return { results: [], messages: [], limitHit: false };
820+
}
821+
},
822+
clearCache: async (_Key: string) => undefined,
823+
};
824+
825+
try {
826+
Services.Search.registerSearchResultProvider("file", 0, Provider); // file
827+
Services.Search.registerSearchResultProvider("file", 1, Provider); // text
828+
return true;
829+
} catch (Error) {
830+
console.warn(
831+
"[SkyBridge] registerSearchResultProvider failed",
832+
Error,
833+
);
834+
return false;
835+
}
836+
};
837+
838+
if (!RegisterLandSearchProvider()) {
839+
const OnReady = () => {
840+
window.removeEventListener(
841+
"cel:workbench-ready",
842+
OnReady as EventListener,
843+
);
844+
RegisterLandSearchProvider();
845+
};
846+
window.addEventListener(
847+
"cel:workbench-ready",
848+
OnReady as EventListener,
849+
{ once: true },
850+
);
851+
}
852+
666853
// ---- SCM bridge (diagnostic only) ----
667854
// Mountain emits `sky://scm/{register,unregister,updateGroup}` when
668855
// extensions call `vscode.scm.createSourceControl(...)`, but the stock

Source/Workbench/Electron/PostHogBridge.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,30 @@ interface BufferedMark {
163163
const ThrottleWindowMs = 10_000;
164164
const ThrottleLimitPerName = Math.max(1, PostHogMaxEventsPerSecond * 2);
165165
const ExceptionThrottleLimitPerSignature = Math.max(1, ThrottleLimitPerName);
166+
// Global exception cap. posthog-js's CDN `array.js` enforces a hard
167+
// 10 events / 10 s per event-name limit on `$exception` - all our
168+
// `captureException` calls share that single slot regardless of
169+
// signature. Without a *global* counter we can still flood the cap by
170+
// firing 11 unique signatures × 1 each. Cap total exception captures
171+
// to a value safely below posthog-js's own limit (7/10 s by default),
172+
// leaving a margin for the `throttle-dropped` summary event and any
173+
// explicit `PH.capture("land:*")` calls that happen to share the slot.
174+
const ExceptionGlobalLimit = Math.max(
175+
1,
176+
Number(
177+
(import.meta.env as any).LAND_POSTHOG_SKY_EXCEPTION_GLOBAL_LIMIT ?? "7",
178+
),
179+
);
166180
const ThrottleCounters = new Map<string, { Count: number; ResetAt: number }>();
167181
const ThrottleDropped = new Map<string, number>();
168182
const ExceptionCounters = new Map<
169183
string,
170184
{ Count: number; ResetAt: number }
171185
>();
172186
const ExceptionDropped = new Map<string, number>();
187+
let ExceptionGlobalCount = 0;
188+
let ExceptionGlobalResetAt = 0;
189+
let ExceptionGlobalDropped = 0;
173190

174191
const ShouldThrottle = (Name: string): boolean => {
175192
const Now = Date.now();
@@ -203,6 +220,19 @@ const ExceptionSignature = (Error: unknown): string => {
203220

204221
const ShouldThrottleException = (Signature: string): boolean => {
205222
const Now = Date.now();
223+
// Global exception cap first - posthog-js's `$exception` slot fills
224+
// up regardless of signature, so a diverse-error boot can still
225+
// blow through the CDN limiter unless the *total* is capped.
226+
if (ExceptionGlobalResetAt <= Now) {
227+
ExceptionGlobalCount = 1;
228+
ExceptionGlobalResetAt = Now + ThrottleWindowMs;
229+
} else {
230+
ExceptionGlobalCount += 1;
231+
if (ExceptionGlobalCount > ExceptionGlobalLimit) {
232+
ExceptionGlobalDropped += 1;
233+
return true;
234+
}
235+
}
206236
const Entry = ExceptionCounters.get(Signature);
207237
if (!Entry || Entry.ResetAt <= Now) {
208238
ExceptionCounters.set(Signature, {
@@ -223,7 +253,13 @@ const ShouldThrottleException = (Signature: string): boolean => {
223253
};
224254

225255
const DrainThrottleMetrics = (PH: any): void => {
226-
if (ThrottleDropped.size === 0 && ExceptionDropped.size === 0) return;
256+
if (
257+
ThrottleDropped.size === 0 &&
258+
ExceptionDropped.size === 0 &&
259+
ExceptionGlobalDropped === 0
260+
) {
261+
return;
262+
}
227263
const Summary: Record<string, number> = {};
228264
for (const [Name, Count] of ThrottleDropped.entries()) {
229265
Summary[Name] = Count;
@@ -234,13 +270,17 @@ const DrainThrottleMetrics = (PH: any): void => {
234270
ExceptionSummary[Signature] = Count;
235271
}
236272
ExceptionDropped.clear();
273+
const GlobalDropped = ExceptionGlobalDropped;
274+
ExceptionGlobalDropped = 0;
237275
// Single event per window - counted as one against the
238276
// throttle itself, so always safe under the limit.
239277
try {
240278
PH.capture?.("land:sky:throttle-dropped", {
241279
$component: "sky",
242280
dropped: Summary,
243281
dropped_exceptions: ExceptionSummary,
282+
dropped_exceptions_global: GlobalDropped,
283+
exception_global_limit: ExceptionGlobalLimit,
244284
window_ms: ThrottleWindowMs,
245285
});
246286
} catch {}

0 commit comments

Comments
 (0)