Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 4 additions & 5 deletions plugins/winui/skills/winui-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ description: "WinUI 3 UI design and XAML correctness — layout planning, contro
> **Before picking controls, search the catalogue.** This skill ships `winui-search.exe` alongside this `SKILL.md`. It indexes 100+ WinUI Gallery controls, every Windows Community Toolkit scenario, and a curated set of platform integration patterns (JumpList, Share, file pickers, drag-drop). Use it to ground every control choice in a real shipping sample **before writing any XAML** — this is the difference between guessing property names and copying canonical code.
>
> ```powershell
> .\winui-search.exe search "<feature description>" # shortlist of matching scenarios
> .\winui-search.exe get <id> # full XAML + C# + pitfall notes
> .\winui-search.exe list # browse everything
> .\winui-search.exe update # refresh embedded snapshots from GitHub
> .\winui-search.exe search "<feature 1>" "<feature 2>" ... # batch one focused query per feature
> .\winui-search.exe get <id 1> <id 2> ... # batch up to 3 IDs — full XAML + C# + pitfall notes
> .\winui-search.exe list # browse all 379 patterns (heavy — prefer search)
> ```
>
> **Workflow:** batch every search you need for the current page or featurepick the best ID from each shortlist → `get` the full code for each → then write XAML using those samples as reference. **Do NOT interleave searching with coding.** Search **one feature per query** — the BM25 scoring rewards focused queries.
> **Workflow:** in **one** `search` call, list every feature you need for the current page (one focused query per feature, not a bag of keywords) → from each shortlist pick the best ID → grab full code with `get` (batch up to 3 IDs per call) → then write XAML using those samples as reference. **Do NOT interleave searching with coding** — front-load all lookups, then code. BM25 rewards focused per-query phrasing, so keep each query about one control or pattern.

#### Step 1: Identify App Type and Anchor Control
| App Type | Anchor Control | Reference App |
Expand Down
Binary file modified plugins/winui/skills/winui-design/winui-search.exe
Binary file not shown.
17 changes: 16 additions & 1 deletion src/tools/winui-search/BM25.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static string[] Tokenize(string text)
{
return NonAlphaRegex().Replace(text.ToLowerInvariant(), " ")
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 1)
.Where(w => w.Length > 1 && !global::StopWords.Common.Contains(w))
.ToArray();
}

Expand Down Expand Up @@ -77,4 +77,19 @@ public static double Score(Doc doc, string[] queryWords, Corpus corpus)
}
return score;
}

/// <summary>
/// Count how many of the given query tokens appear at least once in the doc.
/// Used by SearchEngine for the coverage gate (rejects results where most
/// query terms are OOV — e.g. "find replace text" matching only "text").
/// </summary>
public static int CountHits(Doc doc, string[] queryTokens)
{
int hits = 0;
foreach (var word in queryTokens)
{
if (doc.Tf.TryGetValue(word, out var tf) && tf > 0) hits++;
}
return hits;
}
}
216 changes: 216 additions & 0 deletions src/tools/winui-search/BackgroundUpdater.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
using System.Diagnostics;

/// <summary>
/// Stale-while-revalidate refresher for the on-disk cache under
/// <c>%LOCALAPPDATA%\winui-search\cache\</c>.
///
/// Hot path commands (search/get/list/debug) call <see cref="TryKickoffIfStale"/>
/// after they've answered the user. If the cache hasn't been refreshed from
/// GitHub in <see cref="StaleThreshold"/>, this spawns a detached child
/// <c>winui-search update --background</c> that runs the GitHub fetch and
/// updates the cache. The hot-path process returns immediately; the child
/// outlives it (Windows does not auto-kill children of an exiting parent).
///
/// Concurrency safety:
/// - <c>update.lock</c>: atomic <c>FileMode.CreateNew</c> ensures only one
/// simultaneous spawn wins the race; others see <see cref="IOException"/>
/// and skip silently.
/// - 10-minute TTL on the lock reaps orphans from crashed children.
/// - <c>last-attempt.txt</c>: rate-limits retry to 1 hour after a failed
/// attempt (otherwise a GitHub outage would re-spawn on every search).
///
/// Disable per-process by setting <c>WINUI_SEARCH_NO_BACKGROUND=1</c>.
/// Enable trace logging to <c>%LOCALAPPDATA%\winui-search\cache\background.log</c>
/// by setting <c>WINUI_SEARCH_DEBUG=1</c>.
/// </summary>
internal static class BackgroundUpdater
{
public const string BackgroundFlag = "--background";

private static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(7);
private static readonly TimeSpan RetryAfterFailure = TimeSpan.FromHours(1);
private static readonly TimeSpan LockTtl = TimeSpan.FromMinutes(10);

private static readonly string CacheRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"winui-search", "cache");

private static readonly string LockFile = Path.Combine(CacheRoot, "update.lock");
private static readonly string LastSuccessFile = Path.Combine(CacheRoot, "last-github-update.txt");
private static readonly string LastAttemptFile = Path.Combine(CacheRoot, "last-github-attempt.txt");

/// <summary>True if the current process was launched as a background updater child.</summary>
public static bool IsBackgroundInvocation(string[] args) =>
args.Any(a => string.Equals(a, BackgroundFlag, StringComparison.Ordinal));

private static bool IsDisabled() =>
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINUI_SEARCH_NO_BACKGROUND"));

Comment thread
lei9444 marked this conversation as resolved.
/// <summary>
/// Spawn a detached <c>winui-search update --background</c> if cache hasn't been
/// refreshed from GitHub in <see cref="StaleThreshold"/> and no other update is
/// in flight or recently attempted. Returns immediately; the child runs detached.
/// Best-effort: any failure is swallowed so the hot path is never affected.
/// </summary>
public static void TryKickoffIfStale()
{
try
{
if (IsDisabled()) return;

var lastSuccess = ReadTimestamp(LastSuccessFile);
if (lastSuccess.HasValue && DateTime.UtcNow - lastSuccess.Value < StaleThreshold)
return; // cache is fresh enough

var lastAttempt = ReadTimestamp(LastAttemptFile);
if (lastAttempt.HasValue && DateTime.UtcNow - lastAttempt.Value < RetryAfterFailure)
return; // recent attempt — back off

if (!TryAcquireLock()) return;

// Lock acquired; spawn the child. The child clears the lock when done.
var exePath = Environment.ProcessPath;
if (string.IsNullOrEmpty(exePath))
{
ReleaseLock();
return;
}

DebugLog($"Spawning child: {exePath} update {BackgroundFlag}");
var psi = new ProcessStartInfo
{
FileName = exePath,
Arguments = $"update {BackgroundFlag}",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden,
// IMPORTANT: do NOT redirect stdio. With redirected pipes the parent
// implicitly waits for the child's stdout/stderr to close on exit,
// defeating the whole point of fire-and-forget. The child uses
// BackgroundFlag to stay silent on Console (writes only to its log file).
};

var child = Process.Start(psi);
if (child == null)
{
DebugLog("Process.Start returned null");
ReleaseLock();
return;
}
DebugLog($"Spawned child PID={child.Id}");
// Dispose the Process handle immediately so the parent doesn't track the child.
child.Dispose();
// Do NOT WaitForExit — child runs detached, parent returns now.
}
catch (Exception ex)
{
DebugLog($"TryKickoffIfStale failed: {ex.GetType().Name}: {ex.Message}");
try { ReleaseLock(); } catch { /* ignore */ }
}
}

/// <summary>Mark a successful GitHub refresh. Called by the background child after success.</summary>
public static void MarkSuccess()
{
try
{
Directory.CreateDirectory(CacheRoot);
var now = DateTime.UtcNow.ToString("o");
File.WriteAllText(LastSuccessFile, now);
File.WriteAllText(LastAttemptFile, now);
DebugLog($"MarkSuccess @ {now}");
}
catch { /* best-effort */ }
}

/// <summary>Mark a failed GitHub refresh attempt for retry rate-limiting.</summary>
public static void MarkAttempt()
{
try
{
Directory.CreateDirectory(CacheRoot);
File.WriteAllText(LastAttemptFile, DateTime.UtcNow.ToString("o"));
DebugLog("MarkAttempt (failure)");
}
catch { /* best-effort */ }
}

/// <summary>Release the spawn lock. Called by the background child in finally.</summary>
public static void ReleaseLock()
{
try { if (File.Exists(LockFile)) File.Delete(LockFile); } catch { /* best-effort */ }
DebugLog("ReleaseLock");
}

private static readonly bool DebugEnabled =
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINUI_SEARCH_DEBUG"));

private static void DebugLog(string msg)
{
if (!DebugEnabled) return;
try
{
Directory.CreateDirectory(CacheRoot);
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] PID={Environment.ProcessId} {msg}\n";
File.AppendAllText(Path.Combine(CacheRoot, "background.log"), line);
}
catch { /* best-effort */ }
}

/// <summary>Public surface for diagnostic logs from outside this class.</summary>
public static void DebugLogPublic(string msg) => DebugLog(msg);

private static DateTime? ReadTimestamp(string path)
{
try
{
if (!File.Exists(path)) return null;
var text = File.ReadAllText(path).Trim();
if (DateTime.TryParse(text, null,
System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
{
return dt.ToUniversalTime();
}
return null;
}
catch { return null; }
}

private static bool TryAcquireLock()
{
try
{
Directory.CreateDirectory(CacheRoot);

// Reap stale lock from a crashed previous child.
if (File.Exists(LockFile))
{
var age = DateTime.UtcNow - File.GetLastWriteTimeUtc(LockFile);
if (age > LockTtl)
{
try { File.Delete(LockFile); }
catch { return false; }
}
else
{
return false; // Another process owns it.
}
}

// Atomic create-or-fail. Only one process wins.
using var fs = new FileStream(LockFile, FileMode.CreateNew,
FileAccess.Write, FileShare.Read);
using var writer = new StreamWriter(fs);
writer.Write($"{Environment.ProcessId}@{DateTime.UtcNow:o}");
return true;
}
catch (IOException)
{
return false; // Another process won the create-race.
}
catch
{
return false;
}
}
}
39 changes: 39 additions & 0 deletions src/tools/winui-search/CacheVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/// <summary>
/// Single source of truth for the on-disk cache version under
/// <c>%LOCALAPPDATA%\winui-search\cache\</c>. Both <see cref="GalleryFetcher"/>
/// and <see cref="ToolkitFetcher"/> stamp this string into their
/// <c>schema-version.txt</c> on write and require an exact match on read;
/// any mismatch forces a cache miss + rebuild from embedded fallback JSON.
///
/// Bump <see cref="Current"/> whenever ANY cached payload should be discarded:
/// 1. Scenario / tag JSON schema changes (new or removed fields)
/// 2. Embedded <c>Data/*.json</c> content changes (e.g. new tags added,
/// tag-list contents widened) — bump even if the C# schema is unchanged,
/// otherwise existing caches keep serving the older fallback contents.
/// 3. Tag extraction / cleaning logic changes that would alter the cached
/// output for the same input data.
///
/// History:
/// "10" — Notes / Synonyms refactor
/// "11" — Added chip/token/tag entries to tokenizingtextbox in toolkit-tags.json
/// "12" — Added StopWords.TagOnly (text/input/layout/pick/basics/advanced)
/// → tag dicts cleansed; query tokens unchanged.
/// "13" — Toolkit cache now written CLEAN (CleanTagDictionary applied
/// before serialize), matching GalleryFetcher behavior. Old caches
/// contained polluted toolkit tags that were only filtered on read.
/// "14" — Plan A: separate keywords.json cache file; Plan B: HeaderText
/// is now the Sample's Header attribute alone (no " — Description"
/// suffix), Description holds the .md paragraph or XAML Description
/// attribute as a fallback.
/// "15" — Toolkit CleanCSharp now folds platform #if/#else/#endif (keeps
/// WINAPPSDK branch, drops UWP/Uno fallbacks) so emitted samples
/// compile clean against WinAppSDK without the noisy preprocessor.
/// "16" — Toolkit scenario IDs now renumbered in stable sample-path order
/// (was: alphabetical-by-slug, which reshuffled when upstream
/// rewords a Header). Old caches still resolve correctly inside a
/// single process but {controlId}-{N} differs across versions.
/// </summary>
internal static class CacheVersion
{
public const string Current = "16";
}
Loading
Loading