diff --git a/.serena/project.yml b/.serena/project.yml index b9514a5..42e0dfb 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -79,6 +79,38 @@ excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" - +# the name by which the project can be referenced within Serena project_name: "fossiq" + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) included_optional_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: diff --git a/packages/ui/index.html b/packages/ui/index.html index 50793e7..71dab67 100644 --- a/packages/ui/index.html +++ b/packages/ui/index.html @@ -10,7 +10,7 @@ name="description" content="Fossiq - A modern UI for KQL query analysis built with SolidJS" /> - + { + let shouldReloadOnUpdate = false; + navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("Service Worker registered:", registration); + + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + if (!newWorker) { + return; + } + + newWorker.addEventListener("statechange", () => { + if ( + newWorker.state === "installed" && + navigator.serviceWorker.controller + ) { + shouldReloadOnUpdate = true; + newWorker.postMessage({ type: "SKIP_WAITING" }); + } + }); + }); }) .catch((error) => { console.log("Service Worker registration failed:", error); }); + + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (!shouldReloadOnUpdate) { + return; + } + + shouldReloadOnUpdate = false; + window.location.reload(); + }); }); } diff --git a/packages/ui/public/favicon.svg b/packages/ui/public/favicon.svg new file mode 100644 index 0000000..23b7fc7 --- /dev/null +++ b/packages/ui/public/favicon.svg @@ -0,0 +1,6 @@ + diff --git a/packages/ui/public/icon-1024.svg b/packages/ui/public/icon-1024.svg new file mode 100644 index 0000000..d69986f --- /dev/null +++ b/packages/ui/public/icon-1024.svg @@ -0,0 +1,7 @@ + diff --git a/packages/ui/public/icon-192.svg b/packages/ui/public/icon-192.svg new file mode 100644 index 0000000..b441acc --- /dev/null +++ b/packages/ui/public/icon-192.svg @@ -0,0 +1,7 @@ + diff --git a/packages/ui/public/icon-512.svg b/packages/ui/public/icon-512.svg new file mode 100644 index 0000000..6050659 --- /dev/null +++ b/packages/ui/public/icon-512.svg @@ -0,0 +1,6 @@ + diff --git a/packages/ui/public/icon-maskable.svg b/packages/ui/public/icon-maskable.svg new file mode 100644 index 0000000..495b511 --- /dev/null +++ b/packages/ui/public/icon-maskable.svg @@ -0,0 +1,7 @@ + diff --git a/packages/ui/public/manifest.json b/packages/ui/public/manifest.json index c1b5356..a274cc1 100644 --- a/packages/ui/public/manifest.json +++ b/packages/ui/public/manifest.json @@ -7,7 +7,7 @@ "display": "standalone", "display_override": ["window-controls-overlay", "standalone"], "orientation": "portrait-primary", - "theme_color": "#ffffff", + "theme_color": "#0c3b66", "background_color": "#ffffff", "categories": ["productivity", "utilities"], "icons": [ diff --git a/packages/ui/public/sw.js b/packages/ui/public/sw.js index c7f0794..7c4b2d9 100644 --- a/packages/ui/public/sw.js +++ b/packages/ui/public/sw.js @@ -1,75 +1,87 @@ // Service Worker for Fossiq PWA -const CACHE_NAME = "fossiq-v1"; -const ASSETS_TO_CACHE = ["/", "/index.html"]; +// Version is updated during build process +const VERSION = "{{VERSION}}"; +const CACHE_NAME = `fossiq-v${VERSION}`; + +// DuckDB binaries: large, essentially immutable — cache-first +const DUCKDB_PATTERN = /\/(duckdb-[^/]+\.wasm|duckdb-[^/]+\.worker\.js)$/; + +// Vite content-hashed assets: safe to cache forever — cache-first +// Matches e.g. /assets/index-CEEKSdb3.js, /assets/index-D9aWs9Jp.css +const HASHED_ASSET_PATTERN = /\/assets\/.+-[A-Za-z0-9]{8}\.(js|css|woff2?)(\.map)?$/; -// Install event - cache assets self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(ASSETS_TO_CACHE).catch(() => { - console.log("Some assets could not be cached"); - }); - }) - ); + console.log(`[SW] Installing version ${VERSION}`); + // Skip waiting so the new SW takes over immediately on next navigation self.skipWaiting(); }); -// Activate event - clean up old caches self.addEventListener("activate", (event) => { + console.log(`[SW] Activating version ${VERSION}`); event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((cacheName) => cacheName !== CACHE_NAME) - .map((cacheName) => caches.delete(cacheName)) + (async () => { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames.map((name) => { + if (name !== CACHE_NAME) { + console.log(`[SW] Deleting old cache: ${name}`); + return caches.delete(name); + } + return Promise.resolve(); + }) ); - }) + await self.clients.claim(); + })() ); - self.clients.claim(); }); -// Fetch event - serve from cache, fallback to network self.addEventListener("fetch", (event) => { - // Skip non-GET requests - if (event.request.method !== "GET") { - return; - } + if (event.request.method !== "GET") return; + if (!event.request.url.startsWith("http")) return; - event.respondWith( - caches.match(event.request).then((response) => { - if (response) { - return response; - } + const { pathname } = new URL(event.request.url); - return fetch(event.request) - .then((response) => { - // Don't cache non-successful responses - if (!response || response.status !== 200 || response.type === "error") { - return response; - } + if (DUCKDB_PATTERN.test(pathname) || HASHED_ASSET_PATTERN.test(pathname)) { + // Cache-first: serve from cache, fetch+store on miss + event.respondWith(cacheFirst(event.request)); + } else { + // Network-first: always try network, fall back to cache if offline + event.respondWith(networkFirst(event.request)); + } +}); - // Only cache http and https requests - if (!event.request.url.startsWith("http")) { - return response; - } +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; - // Clone the response - const responseToCache = response.clone(); + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; +} - // Cache the fetched response for future use - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseToCache); - }); +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + return new Response("Service unavailable. Please check your connection.", { + status: 503, + statusText: "Service Unavailable", + }); + } +} - return response; - }) - .catch(() => { - // Return a fallback if both cache and network fail - return new Response( - "Service unavailable. Please check your connection.", - { status: 503, statusText: "Service Unavailable" } - ); - }); - }) - ); +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") { + self.skipWaiting(); + } }); diff --git a/packages/ui/src/App.module.css b/packages/ui/src/App.module.css new file mode 100644 index 0000000..d7790f8 --- /dev/null +++ b/packages/ui/src/App.module.css @@ -0,0 +1,49 @@ +.editorPane, +.resultsPane { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-height: 0; +} + +.editorPane { + border-right: 1px solid var(--border-color); +} + +.paneHeader { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-shrink: 0; + background-color: var(--bg-secondary); +} + +.paneHeaderTitle { + font-size: 0.8rem; + font-weight: 700; + margin: 0; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-primary); + opacity: 0.7; + flex: 1; +} + +.paneActions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.editorContainer { + flex: 1; + overflow: auto; + background-color: var(--bg-primary); + border: none; + padding: 0; +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 24e2ff2..58e7fe7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,17 +1,21 @@ import "@picocss/pico"; import "./styles/theme.css"; +import "./styles/base.css"; +import "./styles/codemirror.css"; +import "./styles/utilities.css"; +import styles from "./App.module.css"; import Layout from "./components/Layout"; import Editor from "./components/Editor"; import ResultsTable from "./components/ResultsTable"; import { SchemaProvider, useSchema } from "./contexts/SchemaContext"; import { Component, createSignal, createEffect, Show } from "solid-js"; import { kqlToDuckDB } from "@fossiq/kql-to-duckdb"; +import SplashScreen from "./components/SplashScreen"; const STORAGE_KEY_QUERY = "fossiq-query"; const STORAGE_KEY_RESULTS = "fossiq-results"; const AppContent: Component = () => { - // Load persisted query and results from localStorage const savedQuery = localStorage.getItem(STORAGE_KEY_QUERY) || ""; const savedResults = (() => { try { @@ -29,16 +33,13 @@ const AppContent: Component = () => { const [isRunning, setIsRunning] = createSignal(false); const { conn } = useSchema(); - // Persist query to localStorage when it changes createEffect(() => { localStorage.setItem(STORAGE_KEY_QUERY, query()); }); - // Persist results to localStorage when they change createEffect(() => { const currentResults = results(); if (currentResults.length > 0) { - // Convert BigInt to Number for JSON serialization const serializable = JSON.stringify(currentResults, (_, value) => typeof value === "bigint" ? Number(value) : value ); @@ -63,7 +64,6 @@ const AppContent: Component = () => { console.log("Executing SQL:", sql); const result = await connection.query(sql); - // result.toArray() returns Arrow Rows. toJSON() converts to plain object. const rows = result.toArray().map((row) => row.toJSON()); console.log("Query Results:", rows); setResults(rows); @@ -115,8 +115,8 @@ const AppContent: Component = () => { } editorPane={ -