mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
fix: various ui/ux improvements and duplicate rm'd
This commit is contained in:
@@ -46,7 +46,7 @@ const settings = defineSettings({
|
||||
description: "Reset the search index and storage",
|
||||
trigger: async () => {
|
||||
const confirmed = confirm(
|
||||
"Are you sure you want to reset the search index and storage?",
|
||||
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -55,7 +55,9 @@ const settings = defineSettings({
|
||||
// dynamic chunks to chase, so the button keeps working even when
|
||||
// the settings page has been open across an extension update.
|
||||
await resetSearchIndexes();
|
||||
alert("Search index and storage have been reset successfully.");
|
||||
alert(
|
||||
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
||||
);
|
||||
} catch (e) {
|
||||
alert(
|
||||
"Failed to reset index: " +
|
||||
|
||||
@@ -57,7 +57,9 @@ const settings = defineSettings({
|
||||
title: "Reset Index",
|
||||
description: "Reset the search index and storage",
|
||||
trigger: async () => {
|
||||
const confirmed = confirm("Are you sure you want to reset the search index and storage?");
|
||||
const confirmed = confirm(
|
||||
"Reset the search index and all stored Global Search data?\n\nAfter this, reload this SEQTA tab so indexing can run again and rebuild the index.",
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
try {
|
||||
@@ -116,7 +118,9 @@ const settings = defineSettings({
|
||||
try {
|
||||
await deleteDb("embeddiaDB");
|
||||
await deleteDb("betterseqta-index");
|
||||
alert("Search index and storage have been reset successfully.");
|
||||
alert(
|
||||
"Search index and storage were reset.\n\nReload this tab to regenerate the index.",
|
||||
);
|
||||
} catch (e) {
|
||||
alert("Failed to reset one or more databases: " + String(e) + "\n\nTry closing other browser tabs and try again.");
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ import browser from "webextension-polyfill";
|
||||
export function mountSearchBar(
|
||||
titleElement: Element,
|
||||
api: any,
|
||||
appRef: { current: any; storageChangeHandler?: any; progressHandler?: any },
|
||||
appRef: {
|
||||
current: any;
|
||||
storageChangeHandler?: any;
|
||||
progressHandler?: any;
|
||||
clearDoneFlashTimer?: () => void;
|
||||
},
|
||||
) {
|
||||
if (titleElement.querySelector(".search-trigger")) {
|
||||
return;
|
||||
@@ -24,8 +29,8 @@ export function mountSearchBar(
|
||||
const searchWrapper = document.createElement("div");
|
||||
searchWrapper.className = "search-trigger-wrapper";
|
||||
|
||||
// Anchor lets us absolutely position the progress bar directly beneath
|
||||
// the search button without disturbing the topbar's vertical rhythm.
|
||||
// Anchor stacks button + slim progress strip in one rounded chip (see
|
||||
// `.search-trigger-anchor` in styles.css).
|
||||
const searchAnchor = document.createElement("div");
|
||||
searchAnchor.className = "search-trigger-anchor";
|
||||
|
||||
@@ -35,9 +40,13 @@ export function mountSearchBar(
|
||||
const progressBarWrapper = document.createElement("div");
|
||||
progressBarWrapper.className = "search-progress-bar-wrapper";
|
||||
|
||||
const progressTrack = document.createElement("div");
|
||||
progressTrack.className = "search-progress-track";
|
||||
|
||||
const progressBar = document.createElement("div");
|
||||
progressBar.className = "search-progress-bar";
|
||||
progressBarWrapper.appendChild(progressBar);
|
||||
progressTrack.appendChild(progressBar);
|
||||
progressBarWrapper.appendChild(progressTrack);
|
||||
|
||||
// Use a block-level <div> so the label reliably participates in flex
|
||||
// layout. A <span> defaults to `display: inline`, which silently ignores
|
||||
@@ -54,44 +63,175 @@ export function mountSearchBar(
|
||||
|
||||
// Indexing state
|
||||
let isIndexing = false;
|
||||
/** True while indexing has run until it finishes/fails — used for Done! flash only */
|
||||
let ranIndexingCycle = false;
|
||||
let completedJobs = 0;
|
||||
let totalJobs = 0;
|
||||
let indexingStatus: string | null = null;
|
||||
let doneFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let doneFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Captures `wasIndexing && !indexing` for the current dispatcher tick */
|
||||
let indexingJustStoppedFlag = false;
|
||||
|
||||
const DONE_HOLD_MS = 5000;
|
||||
const DONE_FADE_MS = 550;
|
||||
|
||||
/** Treat as failure copy — plain “Done!” would be misleading */
|
||||
const statusLooksRough = (s: string) =>
|
||||
/\b(fail|error|cancel)\b/i.test(s);
|
||||
|
||||
const truncateStatus = (s: string, max = 44) =>
|
||||
s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
|
||||
const clearDoneFlashTimer = () => {
|
||||
if (doneFlashTimer) {
|
||||
clearTimeout(doneFlashTimer);
|
||||
doneFlashTimer = null;
|
||||
}
|
||||
if (doneFadeTimer) {
|
||||
clearTimeout(doneFadeTimer);
|
||||
doneFadeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateProgressDisplay = () => {
|
||||
if (isIndexing && totalJobs > 0) {
|
||||
const indexingStoppedThisTick = indexingJustStoppedFlag;
|
||||
indexingJustStoppedFlag = false;
|
||||
|
||||
const active = isIndexing && totalJobs > 0;
|
||||
|
||||
// Stray pulses (missing total, 0 completed, etc.) used to hit the idle
|
||||
// branch and call clearDoneFlashTimer(), killing the Done! hold/fade.
|
||||
if (doneFlashTimer !== null || doneFadeTimer !== null) {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
clearDoneFlashTimer();
|
||||
}
|
||||
|
||||
const completionEligible =
|
||||
ranIndexingCycle &&
|
||||
!active &&
|
||||
totalJobs > 0 &&
|
||||
(completedJobs >= totalJobs || indexingStoppedThisTick);
|
||||
|
||||
if (active) {
|
||||
clearDoneFlashTimer();
|
||||
progressBarWrapper.classList.remove("is-rough-complete");
|
||||
progressText.classList.remove(
|
||||
"is-rough",
|
||||
"is-fading-done",
|
||||
"is-done-message",
|
||||
);
|
||||
const percentage = Math.round((completedJobs / totalJobs) * 100);
|
||||
progressBar.style.width = `${Math.max(2, percentage)}%`;
|
||||
progressBarWrapper.classList.add("is-active");
|
||||
searchAnchor.classList.add("is-indexing");
|
||||
searchButton.classList.add("is-indexing");
|
||||
|
||||
if (indexingStatus) {
|
||||
const statusText =
|
||||
indexingStatus.length > 28
|
||||
? indexingStatus.substring(0, 28) + "…"
|
||||
: indexingStatus;
|
||||
progressText.textContent = `${statusText} · ${percentage}%`;
|
||||
progressText.textContent = `${truncateStatus(indexingStatus)} · ${percentage}%`;
|
||||
} else {
|
||||
progressText.textContent = `Indexing ${completedJobs}/${totalJobs} (${percentage}%)`;
|
||||
}
|
||||
progressText.classList.add("is-active");
|
||||
} else {
|
||||
progressBarWrapper.classList.remove("is-active");
|
||||
progressText.classList.remove("is-active");
|
||||
return;
|
||||
}
|
||||
|
||||
if (completionEligible) {
|
||||
// Duplicate end-of-run ticks must not reschedule hold/fade timers
|
||||
if (doneFlashTimer !== null || doneFadeTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rough =
|
||||
indexingStatus != null && statusLooksRough(indexingStatus);
|
||||
|
||||
progressBar.style.width = "0%";
|
||||
progressBarWrapper.classList.remove("is-active");
|
||||
searchAnchor.classList.remove("is-indexing");
|
||||
searchButton.classList.remove("is-indexing");
|
||||
progressText.classList.remove("is-fading-done");
|
||||
|
||||
progressText.textContent = rough ? truncateStatus(indexingStatus!, 52) : "Done!";
|
||||
if (rough) {
|
||||
progressText.classList.add("is-rough");
|
||||
progressBarWrapper.classList.add("is-rough-complete");
|
||||
} else {
|
||||
progressText.classList.remove("is-rough");
|
||||
progressBarWrapper.classList.remove("is-rough-complete");
|
||||
}
|
||||
progressText.classList.add("is-active", "is-done-message");
|
||||
|
||||
doneFlashTimer = setTimeout(() => {
|
||||
doneFlashTimer = null;
|
||||
progressText.classList.add("is-fading-done");
|
||||
doneFadeTimer = setTimeout(() => {
|
||||
doneFadeTimer = null;
|
||||
ranIndexingCycle = false;
|
||||
indexingStatus = null;
|
||||
progressBar.style.width = "0%";
|
||||
progressBarWrapper.classList.remove("is-active");
|
||||
progressBarWrapper.classList.remove("is-rough-complete");
|
||||
searchAnchor.classList.remove("is-indexing");
|
||||
searchButton.classList.remove("is-indexing");
|
||||
progressText.classList.remove(
|
||||
"is-active",
|
||||
"is-rough",
|
||||
"is-fading-done",
|
||||
"is-done-message",
|
||||
);
|
||||
progressText.textContent = "";
|
||||
}, DONE_FADE_MS);
|
||||
}, DONE_HOLD_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
clearDoneFlashTimer();
|
||||
progressBarWrapper.classList.remove("is-active");
|
||||
progressBarWrapper.classList.remove("is-rough-complete");
|
||||
searchAnchor.classList.remove("is-indexing");
|
||||
searchButton.classList.remove("is-indexing");
|
||||
progressText.classList.remove(
|
||||
"is-active",
|
||||
"is-rough",
|
||||
"is-fading-done",
|
||||
"is-done-message",
|
||||
);
|
||||
progressBar.style.width = "0%";
|
||||
progressText.textContent = "";
|
||||
ranIndexingCycle = false;
|
||||
indexingStatus = null;
|
||||
};
|
||||
|
||||
// Listen for indexing progress events
|
||||
const progressHandler = (event: CustomEvent) => {
|
||||
const { completed, total, indexing, status } = event.detail;
|
||||
completedJobs = completed || 0;
|
||||
totalJobs = total || 0;
|
||||
isIndexing = indexing || false;
|
||||
indexingStatus = status || null;
|
||||
const { completed, total, indexing, status } = event.detail as {
|
||||
completed?: number;
|
||||
total?: number;
|
||||
indexing?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
const wasIndexing = isIndexing;
|
||||
|
||||
completedJobs = completed ?? 0;
|
||||
totalJobs = total ?? 0;
|
||||
isIndexing = Boolean(indexing);
|
||||
indexingStatus = status ?? null;
|
||||
indexingJustStoppedFlag = wasIndexing && !isIndexing;
|
||||
|
||||
if (!wasIndexing && isIndexing) ranIndexingCycle = true;
|
||||
if (wasIndexing && !isIndexing) ranIndexingCycle = true;
|
||||
if (totalJobs > 0 && completedJobs >= totalJobs && !isIndexing) {
|
||||
ranIndexingCycle = true;
|
||||
}
|
||||
|
||||
updateProgressDisplay();
|
||||
};
|
||||
|
||||
window.addEventListener('indexing-progress', progressHandler as EventListener);
|
||||
appRef.progressHandler = progressHandler;
|
||||
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
||||
|
||||
const updateSearchButtonDisplay = () => {
|
||||
searchButton.innerHTML = /* html */ `
|
||||
@@ -144,7 +284,12 @@ export function mountSearchBar(
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?: any; progressHandler?: any }) {
|
||||
export function cleanupSearchBar(appRef: {
|
||||
current: any;
|
||||
storageChangeHandler?: any;
|
||||
progressHandler?: any;
|
||||
clearDoneFlashTimer?: () => void;
|
||||
}) {
|
||||
if (appRef.current) {
|
||||
try {
|
||||
unmount(appRef.current);
|
||||
@@ -154,6 +299,13 @@ export function cleanupSearchBar(appRef: { current: any; storageChangeHandler?:
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
appRef.clearDoneFlashTimer?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
appRef.clearDoneFlashTimer = undefined;
|
||||
|
||||
// Remove progress event listener
|
||||
if (appRef.progressHandler) {
|
||||
window.removeEventListener('indexing-progress', appRef.progressHandler as EventListener);
|
||||
|
||||
@@ -15,24 +15,58 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/*
|
||||
* Stacks the clickable row and the progress strip as one visual “chip”
|
||||
* so the bar is flush under the button (no floating gap).
|
||||
*/
|
||||
.search-trigger-anchor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
isolation: isolate; /* new stacking context so the bar's z-index is local */
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
vertical-align: middle;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||
0 3px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.dark .search-trigger-anchor {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 3px 10px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.search-trigger-anchor.is-indexing {
|
||||
/* Very soft “rear card” edge — tweak opacity if SEQTA chrome is noisy */
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||
0 3px 8px rgba(0, 0, 0, 0.14),
|
||||
1px 3px 0 rgba(139, 92, 246, 0.14),
|
||||
0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .search-trigger-anchor.is-indexing {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.05) inset,
|
||||
0 4px 12px rgba(0, 0, 0, 0.5),
|
||||
1px 3px 0 rgba(167, 139, 250, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
position: relative;
|
||||
z-index: 2; /* sits above the progress bar so the bar tucks under */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
padding: 3px 12px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(4px);
|
||||
user-select: none;
|
||||
|
||||
@@ -51,10 +85,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode styles */
|
||||
/* Light mode chip */
|
||||
.search-trigger {
|
||||
background-color: rgba(248, 250, 252, 0.05) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: rgba(248, 250, 252, 0.94) !important;
|
||||
color: #555 !important;
|
||||
|
||||
p {
|
||||
@@ -67,8 +103,10 @@
|
||||
}
|
||||
|
||||
.dark .search-trigger {
|
||||
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: rgba(24, 24, 27, 0.92) !important;
|
||||
color: #aaa !important;
|
||||
|
||||
p {
|
||||
@@ -80,7 +118,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Idle: full pill rounding + closed bottom border on the anchor chip.
|
||||
*/
|
||||
.search-trigger-anchor:not(.is-indexing) .search-trigger {
|
||||
border-radius: 8px !important;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.dark .search-trigger-anchor:not(.is-indexing) .search-trigger {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
.highlight {
|
||||
background-color: rgba(255, 213, 0, 0.3);
|
||||
font-weight: 500;
|
||||
@@ -107,64 +155,74 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress bar that hugs the bottom of the search button like the next
|
||||
* card peeking from a small stack. The bar is intentionally inset on the
|
||||
* sides and slightly shorter than the button so it reads as a stacked
|
||||
* shadow card rather than a separate, floating element.
|
||||
* Thin track flush under `.search-trigger` — same width as chip, shared
|
||||
* `overflow:hidden` rounding on `.search-trigger-anchor`.
|
||||
*/
|
||||
.search-progress-bar-wrapper {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
/*
|
||||
* `top: 100%; margin-top: -6px;` makes the bar slide UP into the button
|
||||
* by 6px while still extending below it. Combined with z-index: 1 (vs
|
||||
* the button's z-index: 2), the bar's top edge tucks under the button
|
||||
* so only the bottom portion peeks out — the card-stack look.
|
||||
*/
|
||||
top: 100%;
|
||||
margin-top: -6px;
|
||||
height: 10px;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0 0 7px 7px;
|
||||
flex: none;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-3px) scaleX(0.94);
|
||||
transform-origin: top center;
|
||||
transition: opacity 0.2s ease, transform 0.25s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.12);
|
||||
transition: height 0.22s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.search-progress-bar-wrapper.is-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scaleX(1);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.dark .search-progress-bar-wrapper {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35);
|
||||
.search-progress-track {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.dark .search-progress-track {
|
||||
background: rgba(248, 250, 252, 0.1);
|
||||
}
|
||||
|
||||
.search-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #2563eb, #3b82f6);
|
||||
transition: width 0.3s ease-out;
|
||||
width: 0%;
|
||||
position: relative;
|
||||
border-radius: 0 0 6px 6px;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #38bdf8, #2563eb);
|
||||
transition:
|
||||
width 0.35s cubic-bezier(0.2, 0.7, 0.35, 1),
|
||||
background 0.25s ease;
|
||||
}
|
||||
|
||||
.search-progress-bar-wrapper.is-rough-complete .search-progress-track {
|
||||
background: rgba(185, 28, 28, 0.12);
|
||||
}
|
||||
|
||||
.dark .search-progress-bar-wrapper.is-rough-complete .search-progress-track {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.search-progress-bar-wrapper.is-rough-complete .search-progress-bar {
|
||||
background: linear-gradient(90deg, #f87171, #dc2626);
|
||||
}
|
||||
|
||||
.search-progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.28),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress label sits as a flex child immediately to the right of the
|
||||
* search button (gap is provided by .search-trigger-wrapper). It's hidden
|
||||
@@ -178,21 +236,57 @@
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, color 0.25s ease;
|
||||
pointer-events: none;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 32px;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.01em;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* While indexing: same neutral label colour as default (only “Done!” is green). */
|
||||
.search-progress-text.is-active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Completed pass — green text only here, not on the strip or chip */
|
||||
.search-progress-text.is-active.is-done-message {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #15803d !important;
|
||||
}
|
||||
|
||||
.dark .search-progress-text.is-active.is-done-message {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
/* After DONE_HOLD_MS, fade out before DOM teardown */
|
||||
.search-progress-text.is-active.is-fading-done {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.45s ease,
|
||||
color 0.25s ease;
|
||||
}
|
||||
|
||||
.dark .search-progress-text {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .search-progress-text.is-active {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.search-progress-text.is-active.is-rough {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dark .search-progress-text.is-active.is-rough {
|
||||
color: #fca5a5;
|
||||
}
|
||||
@@ -133,6 +133,49 @@ function sourcePageForRoute(route: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Programme + metaclass for `/load/courses` POST body or embedded course JSON. */
|
||||
function extractProgrammeMetaclass(
|
||||
requestBody: unknown,
|
||||
entity: unknown,
|
||||
): { programme: number; metaclass: number } | null {
|
||||
const coerce = (value: unknown): number | undefined => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const t = value.trim();
|
||||
if (!t) return undefined;
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const read = (
|
||||
src: Record<string, unknown> | null,
|
||||
): { programme: number; metaclass: number } | null => {
|
||||
if (!src) return null;
|
||||
const programme = coerce(
|
||||
src.programme ?? src.programmeId ?? src.programmeID,
|
||||
);
|
||||
const metaclass = coerce(
|
||||
src.metaclass ?? src.metaclassId ?? src.metaclassID ?? src.subjectId,
|
||||
);
|
||||
if (programme !== undefined && metaclass !== undefined) {
|
||||
return { programme, metaclass };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)) {
|
||||
const r = read(requestBody as Record<string, unknown>);
|
||||
if (r) return r;
|
||||
}
|
||||
if (entity && typeof entity === "object" && !Array.isArray(entity)) {
|
||||
const r = read(entity as Record<string, unknown>);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function entitiesFromPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === "object") {
|
||||
@@ -294,6 +337,9 @@ function synthesizeItems(
|
||||
|
||||
const deepLinkHints = pickDeepLinkHints(entity);
|
||||
const sourcePage = sourcePageForRoute(ctx.route);
|
||||
const coursePm = ctx.route.includes("/load/courses")
|
||||
? extractProgrammeMetaclass(ctx.requestBody, entity)
|
||||
: null;
|
||||
|
||||
out.push(
|
||||
buildIndexItem({
|
||||
@@ -314,6 +360,9 @@ function synthesizeItems(
|
||||
// assessments, courses, etc.
|
||||
...(isPeopleSupport ? { supportRecord: true, priority: "low" } : {}),
|
||||
...deepLinkHints,
|
||||
...(coursePm
|
||||
? { programme: coursePm.programme, metaclass: coursePm.metaclass }
|
||||
: {}),
|
||||
},
|
||||
actionId: "passive",
|
||||
renderComponentId: "passive",
|
||||
|
||||
@@ -298,16 +298,36 @@ export class VectorWorkerManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.progressCallback = onProgress || null;
|
||||
this.updateActivity();
|
||||
// Wait until the worker reports a terminal status. Previously this method
|
||||
// returned as soon as the job was queued, so indexers.ts continued into
|
||||
// stopHeartbeat/loadAll/loadDynamicItems on the main thread while
|
||||
// vectorization was still running — blocking indexing-progress handlers
|
||||
// and freezing the chip on “Vectorization in progress”.
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const wrap: ProgressCallback = (data) => {
|
||||
onProgress?.(data);
|
||||
if (
|
||||
!settled &&
|
||||
(data.status === "complete" ||
|
||||
data.status === "error" ||
|
||||
data.status === "cancelled")
|
||||
) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.progressCallback = wrap;
|
||||
this.updateActivity();
|
||||
|
||||
console.debug(
|
||||
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
||||
);
|
||||
console.debug(
|
||||
`Sending ${uniqueItems.length} unique items to worker for processing.`,
|
||||
);
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: "process",
|
||||
data: { items: uniqueItems },
|
||||
this.worker!.postMessage({
|
||||
type: "process",
|
||||
data: { items: uniqueItems },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { CombinedResult } from "../core/types";
|
||||
import type { IndexItem } from "../indexing/types";
|
||||
|
||||
function toFiniteNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const t = value.trim();
|
||||
if (!t) return undefined;
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Same SPA destination as handlers for `course` / `subjectcourse` / passive `courses`. */
|
||||
function shouldDedupeAsSameCourseSPA(item: IndexItem): boolean {
|
||||
if (item.actionId === "subjectassessment") return false;
|
||||
if (item.metadata?.type === "assessments") return false;
|
||||
|
||||
if (item.renderComponentId === "course") return true;
|
||||
if (item.actionId === "course") return true;
|
||||
if (item.actionId === "subjectcourse") return true;
|
||||
if (
|
||||
item.actionId === "passive" &&
|
||||
item.metadata?.sourcePage === "/courses"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function courseDestinationKey(item: IndexItem): string | undefined {
|
||||
if (!shouldDedupeAsSameCourseSPA(item)) return undefined;
|
||||
const md = item.metadata ?? {};
|
||||
const programme = toFiniteNumber(
|
||||
md.programme ?? md.programmeId ?? md.programmeID,
|
||||
);
|
||||
const metaclass = toFiniteNumber(
|
||||
md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId,
|
||||
);
|
||||
if (programme === undefined || metaclass === undefined) return undefined;
|
||||
return `course:${programme}:${metaclass}`;
|
||||
}
|
||||
|
||||
function isPassiveLike(item: IndexItem): boolean {
|
||||
return (
|
||||
item.actionId === "passive" || item.metadata?.source === "passive"
|
||||
);
|
||||
}
|
||||
|
||||
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||
const aP = isPassiveLike(a);
|
||||
const bP = isPassiveLike(b);
|
||||
if (aP && !bP) return b;
|
||||
if (!aP && bP) return a;
|
||||
// Prefer curated job row (courses store) vs other categories
|
||||
if (a.category === "courses" && b.category !== "courses") return a;
|
||||
if (b.category === "courses" && a.category !== "courses") return b;
|
||||
if (a.renderComponentId === "course" && b.renderComponentId !== "course")
|
||||
return a;
|
||||
if (b.renderComponentId === "course" && a.renderComponentId !== "course")
|
||||
return b;
|
||||
const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0;
|
||||
const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0;
|
||||
return ad >= bd ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses multiple index rows that open the same course hash route
|
||||
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
|
||||
*/
|
||||
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||
const winners = new Map<string, IndexItem>();
|
||||
|
||||
for (const item of items) {
|
||||
const key = courseDestinationKey(item);
|
||||
if (!key) continue;
|
||||
const prev = winners.get(key);
|
||||
winners.set(
|
||||
key,
|
||||
prev ? pickBetterCourseNavDuplicate(prev, item) : item,
|
||||
);
|
||||
}
|
||||
|
||||
const seenCanon = new Set<string>();
|
||||
const out: IndexItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const key = courseDestinationKey(item);
|
||||
if (!key) {
|
||||
out.push(item);
|
||||
continue;
|
||||
}
|
||||
if (seenCanon.has(key)) continue;
|
||||
seenCanon.add(key);
|
||||
out.push(winners.get(key)!);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function dynamicCourseKey(row: CombinedResult): string | undefined {
|
||||
if (row.type !== "dynamic") return undefined;
|
||||
return courseDestinationKey(row.item as IndexItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final pass after hybrid expansion: vector-only recall can still surface a
|
||||
* second row for the same `/courses/P:M` SPA route using a stale passive id.
|
||||
*/
|
||||
export function dedupeCombinedResultsByCourseNav(
|
||||
results: CombinedResult[],
|
||||
): CombinedResult[] {
|
||||
const best = new Map<string, CombinedResult>();
|
||||
|
||||
for (const r of results) {
|
||||
const key = dynamicCourseKey(r);
|
||||
if (!key) continue;
|
||||
const prev = best.get(key);
|
||||
if (!prev) {
|
||||
best.set(key, r);
|
||||
continue;
|
||||
}
|
||||
const aItem = prev.item as IndexItem;
|
||||
const bItem = r.item as IndexItem;
|
||||
const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem);
|
||||
const envelope = winnerItem.id === aItem.id ? prev : r;
|
||||
best.set(key, {
|
||||
...envelope,
|
||||
score: Math.max(prev.score, r.score),
|
||||
id: winnerItem.id,
|
||||
item: winnerItem,
|
||||
});
|
||||
}
|
||||
|
||||
const seenCanon = new Set<string>();
|
||||
const out: CombinedResult[] = [];
|
||||
|
||||
for (const r of results) {
|
||||
const key = dynamicCourseKey(r);
|
||||
if (!key) {
|
||||
out.push(r);
|
||||
continue;
|
||||
}
|
||||
if (seenCanon.has(key)) continue;
|
||||
seenCanon.add(key);
|
||||
out.push(best.get(key)!);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getStaticCommands, type StaticCommandItem } from "../core/commands";
|
||||
import { getDynamicItems } from "../utils/dynamicItems";
|
||||
import type { CombinedResult } from "../core/types";
|
||||
import type { IndexItem } from "../indexing/types";
|
||||
import { dedupeCombinedResultsByCourseNav, dedupeIndexItemsForSearch } from "./dedupeIndexItems";
|
||||
import { hybridSearchWithExpansion } from "./hybridSearch";
|
||||
import {
|
||||
getLexicalMatchQuality,
|
||||
@@ -50,8 +51,9 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export function createSearchIndexes() {
|
||||
clearSearchCache();
|
||||
const commands = getStaticCommands();
|
||||
const dynamicItems = getDynamicItems();
|
||||
const dynamicItems = dedupeIndexItemsForSearch(getDynamicItems());
|
||||
|
||||
// Optimized command search options
|
||||
const commandOptions = {
|
||||
@@ -384,10 +386,21 @@ export async function performSearch(
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
const dedupedResults = dedupeCombinedResultsByCourseNav(allResults);
|
||||
dedupedResults.sort((a, b) => {
|
||||
if (a.type === "command" && b.type === "dynamic") {
|
||||
return b.score - a.score - 10;
|
||||
}
|
||||
if (a.type === "dynamic" && b.type === "command") {
|
||||
return b.score - a.score + 10;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
// Cache results for queries longer than 2 chars
|
||||
if (trimmedQuery.length > 2) {
|
||||
setCachedResults(trimmedQuery, allResults);
|
||||
setCachedResults(trimmedQuery, dedupedResults);
|
||||
}
|
||||
|
||||
return allResults;
|
||||
return dedupedResults;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||
|
||||
|
||||
<h1>3.6.5 - Seek and CTRL-Found: Global Search won’t CTRL-fuse you anymore</h1>
|
||||
<li>Global Search: hybrid search and indexing tuned so vector + keyword results behave more reliably and stop past assessments/courses from disappearing from results.</li>
|
||||
<li>Indexing progress bar/status cleaned up—with a cheeky green “Done!” when a pass actually finishes—and vectorisation no longer ghosts the UI on “still in progress.”</li>
|
||||
<li>Duplicate course tiles that opened the same page are folded into one result (indexed job vs passive SEQTA captures vs subjects shortcut).</li>
|
||||
<li>Reset Index now tells you to reload the SEQTA tab so everything can regenerate from scratch.</li>
|
||||
|
||||
<h1>3.6.4 - DM Folders, Theme flavours and fixes, Upcoming Assements improvement</h1>
|
||||
<li>Added advanced colour adjustments variables for theme customisation.</li>
|
||||
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
|
||||
|
||||
Reference in New Issue
Block a user