Skip to content

Retain last folder on each tab for file functions - #4001

Closed
haslinghuis wants to merge 1 commit into
betaflight:masterfrom
haslinghuis:remember-path
Closed

haslinghuis wants to merge 1 commit into
betaflight:masterfrom
haslinghuis:remember-path

Conversation

@haslinghuis

@haslinghuis haslinghuis commented Jun 2, 2024 •

Copy link
Copy Markdown
Member

Some reference:

EDIT: New goal will be to create a single workspace and use board name to store all different files.
Now awaiting completion of Vue migration.

===

Workspace Directory — Implementation Plan

PR: #4001
Goal: Replace per-tab "remember last directory" with a persistent workspace folder that auto-organizes files into [board]/[category] subfolders.


1. Concept

The user picks a root workspace folder once. The FileSystemDirectoryHandle is stored in IndexedDB (the only storage API that can serialize directory handles). On subsequent visits, Chrome 122+ persistent permissions allow the app to re-acquire readwrite access without a picker — only a one-time permission prompt on first use per session.

When no workspace is configured, the original file-picker behavior is preserved unchanged.

Directory Structure

[Workspace Root]/                          ← picked once via showDirectoryPicker()
  └── MATEKF405/                           ← auto-created, sanitized FC.CONFIG.boardName
       ├── cli_backup/                     ← CLI tab saves, presets backup, AutoBackup
       ├── blackbox/                       ← onboard flight logs
       ├── logs/                           ← telemetry CSV logs
       ├── firmware/                       ← hex files
       ├── osd/                            ← font files (MCM)
       ├── vtx/                            ← VTX config (JSON, Lua)
       └── logos/                          ← boot logo images

2. Platform Support

Platform showDirectoryPicker Workspace Fallback
Chrome / Edge 122+ (desktop) Yes Full + persistent permissions —
Chrome 86–121 (desktop) Yes Works, permission prompt each session —
Android (Capacitor) No No Original file picker
Firefox / Safari No No Original file picker

Detection: typeof window.showDirectoryPicker === "function"


3. Chrome API Reference

Storing handles in IndexedDB

Per Chrome docs:

// Store
const handle = await window.showDirectoryPicker();
// handle is serializable — put it directly into IndexedDB

// Retrieve & verify permission
const storedHandle = await db.get('handles', 'workspace_root');
if (storedHandle) {
    const status = await storedHandle.queryPermission({ mode: 'readwrite' });
    if (status !== 'granted') {
        // Must be called from user gesture
        await storedHandle.requestPermission({ mode: 'readwrite' });
    }
}

Persistent permissions (Chrome 122+)

Per Chrome blog:

  • First grant shows 3-way prompt: "Allow this time" / "Allow on every visit" / "Don't allow"
  • "Allow on every visit" → no future prompts needed
  • Installed PWAs get automatic persistence on first grant
  • Falls back gracefully on older Chrome (permission prompt each session)

4. Architecture

Why extend FileSystem.js + add a composable

The codebase has two consumption patterns:

  1. Vue components/composables — CliTab.vue, useVtx.js, OptionsTab.vue, etc.
  2. Non-Vue singletons — AutoBackup.js (imports FileSystem directly)

Solution: Add workspace methods to the FileSystem singleton (accessible everywhere), plus a thin useWorkspace.js composable for Vue-reactive state in OptionsTab.vue.

Category Constants

// In FileSystem.js
export const WORKSPACE_CATEGORIES = {
    CLI_BACKUP: "cli_backup",
    BLACKBOX:   "blackbox",
    LOGS:       "logs",
    FIRMWARE:   "firmware",
    OSD:        "osd",
    VTX:        "vtx",
    LOGOS:      "logos",
};

Storage

What Where Why
FileSystemDirectoryHandle IndexedDB BetaflightApp → store handles → key workspace_root Only IndexedDB can serialize handles
workspaceEnabled toggle Not needed — workspace is "enabled" when a handle exists in IndexedDB Simplifies logic

No new npm dependencies — raw IndexedDB API (we store exactly one key).


5. File Changes

5a. src/js/FileSystem.js — Core Workspace API

Extend the existing singleton. Zero breaking changes to existing pickSaveFile / pickOpenFile.

import GUI from "./gui";
import FC from "./fc";

const DB_NAME = "BetaflightApp";
const STORE_NAME = "handles";
const WORKSPACE_KEY = "workspace_root";

export const WORKSPACE_CATEGORIES = {
    CLI_BACKUP: "cli_backup",
    BLACKBOX:   "blackbox",
    LOGS:       "logs",
    FIRMWARE:   "firmware",
    OSD:        "osd",
    VTX:        "vtx",
    LOGOS:      "logos",
};

class FileSystem {
    constructor() {
        this._workspaceHandle = null;
    }

    // ── IndexedDB (raw API, no dependencies) ────────────────

    _openDB() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, 1);
            request.onupgradeneeded = (e) =>
                e.target.result.createObjectStore(STORE_NAME);
            request.onsuccess = (e) => resolve(e.target.result);
            request.onerror = (e) => reject(e.target.error);
        });
    }

    async _getStoredHandle() {
        const db = await this._openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, "readonly");
            const req = tx.objectStore(STORE_NAME).get(WORKSPACE_KEY);
            req.onsuccess = () => resolve(req.result ?? null);
            req.onerror = () => reject(req.error);
        });
    }

    async _storeHandle(handle) {
        const db = await this._openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, "readwrite");
            const req = tx.objectStore(STORE_NAME).put(handle, WORKSPACE_KEY);
            req.onsuccess = () => resolve();
            req.onerror = () => reject(req.error);
        });
    }

    async _removeHandle() {
        const db = await this._openDB();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, "readwrite");
            const req = tx.objectStore(STORE_NAME).delete(WORKSPACE_KEY);
            req.onsuccess = () => resolve();
            req.onerror = () => reject(req.error);
        });
    }

    // ── Workspace lifecycle ─────────────────────────────────

    /** Platform check — false on Android / Firefox / Safari */
    isWorkspaceSupported() {
        return typeof window.showDirectoryPicker === "function";
    }

    /** True when a handle is loaded in memory */
    isWorkspaceConfigured() {
        return this._workspaceHandle !== null;
    }

    /** Call on app startup — loads handle from IndexedDB (no permission prompt) */
    async initWorkspace() {
        if (!this.isWorkspaceSupported()) return;
        try {
            this._workspaceHandle = await this._getStoredHandle();
        } catch (err) {
            console.warn("Failed to load workspace handle:", err);
        }
    }

    /** User gesture: open directory picker → store handle in IndexedDB */
    async selectWorkspace() {
        const handle = await window.showDirectoryPicker({ mode: "readwrite" });
        await this._storeHandle(handle);
        this._workspaceHandle = handle;
        return handle.name;
    }

    /** Remove stored handle, revert to picker behavior */
    async clearWorkspace() {
        await this._removeHandle();
        this._workspaceHandle = null;
    }

    /** Name of the workspace root folder (for display in Options) */
    getWorkspaceName() {
        return this._workspaceHandle?.name ?? null;
    }

    // ── Permission ──────────────────────────────────────────

    /**
     * Check / request readwrite permission on stored handle.
     * MUST be called from user gesture context (click handler).
     * Chrome 122+: "Allow on every visit" makes this automatic.
     */
    async ensureWorkspacePermission() {
        if (!this._workspaceHandle) return false;

        const opts = { mode: "readwrite" };
        if ((await this._workspaceHandle.queryPermission(opts)) === "granted") {
            return true;
        }
        // requestPermission needs user gesture
        return (await this._workspaceHandle.requestPermission(opts)) === "granted";
    }

    // ── Board name resolution ───────────────────────────────

    /**
     * Best available board name, sanitized for filesystem.
     * @param {string} [override] - explicit board/target name (e.g. firmware flasher dropdown)
     */
    getBoardName(override) {
        const raw = override || FC.CONFIG.boardName || FC.CONFIG.targetName || "unknown_board";
        return raw.replace(/[^a-z0-9_-]/gi, "_");
    }

    // ── Workspace file operations ───────────────────────────

    /**
     * Get or create [root]/[board]/[category] directory handle.
     * @param {string} boardName - raw or sanitized board name
     * @param {string} category  - one of WORKSPACE_CATEGORIES values
     */
    async getSubfolder(boardName, category) {
        const safeName = this.getBoardName(boardName);
        const boardDir = await this._workspaceHandle.getDirectoryHandle(
            safeName, { create: true },
        );
        return boardDir.getDirectoryHandle(category, { create: true });
    }

    /**
     * Save file directly to workspace — no picker shown.
     * Returns { name, path } on success.
     */
    async saveToWorkspace(boardName, category, filename, contents) {
        const dir = await this.getSubfolder(boardName, category);
        const fileHandle = await dir.getFileHandle(filename, { create: true });
        const writable = await fileHandle.createWritable();
        await writable.write(contents);
        await writable.close();
        const safeName = this.getBoardName(boardName);
        return { name: filename, path: `${safeName}/${category}/${filename}` };
    }

    /**
     * Save with streaming (for large files like blackbox logs).
     * Returns a writable stream + path info.
     */
    async openWorkspaceFileForWriting(boardName, category, filename) {
        const dir = await this.getSubfolder(boardName, category);
        const fileHandle = await dir.getFileHandle(filename, { create: true });
        const writable = await fileHandle.createWritable({ keepExistingData: false });
        const safeName = this.getBoardName(boardName);
        return {
            writable,
            path: `${safeName}/${category}/${filename}`,
        };
    }

    // ── Existing methods (unchanged) ────────────────────────

    _createFile(fileHandle) { /* ... existing ... */ }
    async pickSaveFile(suggestedName, description, extension) { /* ... existing ... */ }
    async pickOpenFile(description, extension) { /* ... existing ... */ }
    async verifyPermission(file, withWrite) { /* ... existing ... */ }
    async writeFile(file, contents) { /* ... existing ... */ }
    async readFile(file) { /* ... existing ... */ }
    async readFileAsBlob(file) { /* ... existing ... */ }
    async openFile(file) { /* ... existing ... */ }
    async writeChunck(writable, chunk) { /* ... existing ... */ }
    async closeFile(writable) { /* ... existing ... */ }
}

export default new FileSystem();

5b. src/composables/useWorkspace.js — Vue Reactive Wrapper

Thin composable for OptionsTab and any Vue component that needs reactive workspace state.

import { ref, readonly } from "vue";
import FileSystem from "../js/FileSystem";

// Shared reactive state (module-level — shared across all component instances)
const workspaceName = ref(FileSystem.getWorkspaceName());
const isConfigured = ref(FileSystem.isWorkspaceConfigured());
const isSupported = FileSystem.isWorkspaceSupported(); // static, never changes

function refreshState() {
    workspaceName.value = FileSystem.getWorkspaceName();
    isConfigured.value = FileSystem.isWorkspaceConfigured();
}

export function useWorkspace() {
    async function selectFolder() {
        const name = await FileSystem.selectWorkspace();
        refreshState();
        return name;
    }

    async function clearFolder() {
        await FileSystem.clearWorkspace();
        refreshState();
    }

    return {
        isSupported,
        isConfigured: readonly(isConfigured),
        workspaceName: readonly(workspaceName),
        selectFolder,
        clearFolder,
    };
}

5c. src/components/tabs/OptionsTab.vue — Workspace Setting UI

Add a new "File Storage" settings box. Follows the existing pattern (gui_box with spacer).

Template (add after "Main Options" box, ~line 99)

<!-- File Storage Box -->
<div class="gui_box" v-if="workspace.isSupported">
    <div class="gui_box_titlebar">
        <div class="spacer_box_title" v-html="$t('optionsFileStorage')"></div>
    </div>
    <div class="spacer">
        <div class="workspaceFolder margin-bottom">
            <div class="workspace-row">
                <span class="freelabel" v-html="$t('optionsWorkspaceFolder')"></span>
                <span v-if="workspace.isConfigured.value" class="workspace-path">
                    {{ workspace.workspaceName.value }}
                </span>
                <span v-else class="workspace-path hint">
                    {{ $t('optionsWorkspaceNotSet') }}
                </span>
            </div>
            <div class="workspace-actions">
                <button class="btn" @click="onSelectWorkspace">
                    {{ workspace.isConfigured.value
                        ? $t('optionsWorkspaceChange')
                        : $t('optionsWorkspaceSelect') }}
                </button>
                <button
                    v-if="workspace.isConfigured.value"
                    class="btn"
                    @click="onClearWorkspace"
                >
                    {{ $t('optionsWorkspaceClear') }}
                </button>
            </div>
            <div class="hint-text">
                <span v-html="$t('optionsWorkspaceHint')"></span>
            </div>
        </div>
    </div>
</div>

Script (additions to setup())

import { useWorkspace } from "../../composables/useWorkspace";

// Inside setup():
const workspace = useWorkspace();

async function onSelectWorkspace() {
    try {
        await workspace.selectFolder();
    } catch (err) {
        if (err.name !== "AbortError") {
            console.error("Workspace selection failed:", err);
        }
    }
}

async function onClearWorkspace() {
    await workspace.clearFolder();
}

// Return:
return {
    settings,
    availableLanguages,
    handleNotificationsChange,
    workspace,
    onSelectWorkspace,
    onClearWorkspace,
};

5d. App Startup — src/js/main.js

Add workspace initialization early in the startup sequence (after DOM ready, before tab loading):

import FileSystem from "./FileSystem";

// In appReady() or equivalent init function:
await FileSystem.initWorkspace();

This loads the stored handle from IndexedDB. No permission prompt — that happens on first user-gesture file operation.


5e. Consumer Changes — Save Operations

Every save site gets a try-workspace-or-fallback wrapper. The pattern is identical across all tabs:

import FileSystem, { WORKSPACE_CATEGORIES } from "../js/FileSystem";

// Workspace-aware save helper
async function workspaceSave(category, filename, data, description, extension) {
    if (FileSystem.isWorkspaceConfigured()) {
        try {
            const hasPermission = await FileSystem.ensureWorkspacePermission();
            if (hasPermission) {
                const board = FileSystem.getBoardName();
                const result = await FileSystem.saveToWorkspace(board, category, filename, data);
                gui_log(i18n.getMessage("workspaceSavedTo", { path: result.path }));
                return;
            }
        } catch (err) {
            console.warn("Workspace save failed, falling back to picker:", err);
        }
    }

    // Fallback: original file picker
    const file = await FileSystem.pickSaveFile(filename, description, extension);
    if (file) {
        await FileSystem.writeFile(file, data);
    }
}

Note: This helper could live in FileSystem.js as a convenience method, or be inlined at each call site. Keeping it in FileSystem.js as saveWithWorkspaceFallback() reduces duplication.

Per-File Changes

File Location Category Change
useCli.js saveFile() CLI_BACKUP Wrap save in workspace fallback
useCli.js loadFile() — Keep picker (user loads arbitrary files)
FirmwareFlasherTab.vue save hex handler FIRMWARE Workspace save; use self.selectedBoard as board override
FirmwareFlasherTab.vue load hex handler — Keep picker
OnboardLoggingTab.vue prepare_file() BLACKBOX Workspace save (streaming via openWorkspaceFileForWriting)
LoggingTab.vue prepare_file() LOGS Workspace save (streaming)
useVtx.js save_lua(), save_json() VTX Workspace save
useVtx.js load_json() — Keep picker
OsdTab.vue save font handler OSD Workspace save
OsdTab.vue FONT.openFontFile() — Keep picker
PresetsTab.vue onSaveConfigClick() CLI_BACKUP Workspace save
PresetsTab.vue onLoadConfigClick() — Keep picker
AutoBackup.js save() CLI_BACKUP Workspace save (zero-picker backup!)
LogoManager.js openImage() — Keep picker (open-only)

Open operations stay with picker

showOpenFilePicker doesn't support setting an initial directory from a handle. Building a custom file browser UI is out of scope. Users may also want to open files from arbitrary locations. Save = workspace, Open = picker.


5f. AutoBackup.js — Zero-Picker Backup (Key UX Win)

This is the biggest improvement. Currently AutoBackup shows a save dialog mid-flash. With workspace:

// In AutoBackup.save():
async save(data) {
    const prefix = "cli_backup";
    const suffix = "txt";
    const filename = generateFilename(prefix, suffix);
    let result = false;

    try {
        // Try workspace first (no picker!)
        if (FileSystem.isWorkspaceConfigured()) {
            const hasPermission = await FileSystem.ensureWorkspacePermission();
            if (hasPermission) {
                const board = FileSystem.getBoardName();
                const saved = await FileSystem.saveToWorkspace(
                    board, WORKSPACE_CATEGORIES.CLI_BACKUP, filename, data,
                );
                console.log("Backup saved to workspace:", saved.path);
                result = true;
                return;
            }
        }

        // Fallback: file picker
        const file = await FileSystem.pickSaveFile(
            filename,
            i18n.getMessage("fileSystemPickerFiles", { typeof: suffix.toUpperCase() }),
            `.${suffix}`,
        );
        if (file) {
            await FileSystem.writeFile(file, data);
            result = true;
        }
    } catch (error) {
        console.error("Error saving config:", error);
    } finally {
        if (this.callback) {
            this.callback(result);
        }
    }
}

5g. Board Name for Firmware Flasher (Special Case)

The firmware flasher operates in two modes:

Mode Board name source Example
Connected to FC FC.CONFIG.boardName "MATEKF405"
Disconnected / pre-flash Selected target from dropdown "BETAFLIGHTF4"

Pass the selected board as an override:

// In FirmwareFlasherTab.vue save handler:
const board = FileSystem.getBoardName(self.selectedBoard);
await FileSystem.saveToWorkspace(board, WORKSPACE_CATEGORIES.FIRMWARE, filename, data);

For AutoBackup (always runs while connected), FC.CONFIG.boardName is always available — no override needed.


5h. i18n Strings

Add to src/locales/en/messages.json:

"optionsFileStorage": { "message": "File Storage" },
"optionsWorkspaceFolder": { "message": "Workspace Folder" },
"optionsWorkspaceNotSet": { "message": "Not configured — files will use the system save dialog" },
"optionsWorkspaceSelect": { "message": "Select Folder" },
"optionsWorkspaceChange": { "message": "Change Folder" },
"optionsWorkspaceClear": { "message": "Clear" },
"optionsWorkspaceHint": { "message": "Choose a workspace folder to automatically organize saved files by board name. Logs, backups, and firmware will be saved without a file dialog." },
"workspaceSavedTo": { "message": "Saved to workspace: {{path}}" }

6. Error Handling

Every workspace operation is wrapped in try/catch with picker fallback. The app never breaks if workspace is misconfigured.

Scenario Behavior
Workspace not configured Skip straight to original picker
Permission denied / dismissed Log warning, fall through to picker
Board name unavailable Use "unknown_board" subfolder
IndexedDB unavailable initWorkspace() catches error silently, workspace stays unconfigured
showDirectoryPicker unsupported isWorkspaceSupported() → false, workspace UI hidden in Options
Subfolder creation fails (disk full, etc.) Catch error, fall through to picker
Handle stale (folder deleted/moved) getDirectoryHandle throws, caught → picker fallback

7. Permission Flow

App startup
  │
  ├─ initWorkspace() → load handle from IndexedDB (no prompt)
  │
  User clicks save (first time this session)
  │
  ├─ ensureWorkspacePermission()
  │     ├─ queryPermission() === "granted" → proceed (Chrome 122+ "every visit")
  │     └─ queryPermission() === "prompt"
  │           └─ requestPermission() → browser shows prompt
  │                 ├─ "Allow on every visit" → granted, no future prompts
  │                 ├─ "Allow this time" → granted for this session
  │                 └─ "Don't allow" → fall through to picker
  │
  └─ saveToWorkspace() → writes file, no dialog

8. Implementation Phases

Phase 1 (this PR)

  • Workspace methods in FileSystem.js
  • useWorkspace.js composable
  • Workspace UI in OptionsTab.vue
  • initWorkspace() call in app startup
  • Save-to-workspace in all tabs (with picker fallback)
  • AutoBackup zero-picker save
  • i18n strings

Phase 2 (future)

  • "Recent files" list from workspace for open operations
  • Status indicator in header/footer showing active workspace
  • Workspace browser panel

Phase 3 (future)

  • Android support via @capacitor/filesystem using app internal storage as workspace root

9. File Impact Summary

Action File
Major edit src/js/FileSystem.js — workspace API + IndexedDB + categories
New file src/composables/useWorkspace.js — Vue reactive wrapper
Edit src/components/tabs/OptionsTab.vue — "File Storage" settings box
Edit src/js/main.js — initWorkspace() on startup
Edit src/composables/useCli.js — workspace-aware save
Edit src/composables/useVtx.js — workspace-aware save
Edit src/components/tabs/FirmwareFlasherTab.vue — workspace-aware save + board override
Edit src/components/tabs/OnboardLoggingTab.vue — workspace-aware streaming save
Edit src/components/tabs/LoggingTab.vue — workspace-aware streaming save
Edit src/components/tabs/OsdTab.vue — workspace-aware save
Edit src/components/tabs/PresetsTab.vue — workspace-aware save
Edit src/js/utils/AutoBackup.js — workspace-aware save (zero picker)
Edit src/locales/en/messages.json — i18n strings
No change src/js/LogoManager.js — open-only, keeps picker

10. Testing Checklist

  • Options: workspace select / change / clear buttons
  • Options: workspace UI hidden on unsupported platforms (Android, Firefox)
  • Permission prompt appears on first save after reload (Chrome < 122)
  • No permission prompt after "Allow on every visit" (Chrome 122+)
  • Board subfolder created with correct sanitized name
  • Category subfolders created for each tab's file type
  • Multiple boards → separate subfolders
  • Fallback to picker when workspace not configured
  • Fallback to picker when permission denied
  • AutoBackup saves without any dialog when workspace configured
  • Streaming saves (blackbox, logging) work through workspace
  • Firmware flasher uses selected target name when disconnected
  • Stale handle (deleted folder) falls back to picker gracefully
  • No regression on existing file picker behavior when workspace not set
  • IndexedDB handle persists across browser sessions

@haslinghuis haslinghuis added this to the 11.0 milestone Jun 2, 2024
@haslinghuis haslinghuis self-assigned this Jun 2, 2024
@netlify

netlify Bot commented Jun 2, 2024 •

Copy link
Copy Markdown

✅ Deploy Preview for origin-betaflight-app ready!

Name Link
🔨 Latest commit 8e0a9f6
🔍 Latest deploy log https://app.netlify.com/sites/origin-betaflight-app/deploys/6779cdfa932f710008f55030
😎 Deploy Preview https://deploy-preview-4001.dev.app.betaflight.com
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@haslinghuis haslinghuis changed the title Remember file path on each tab Retain last folder on each tab for file functions Jun 2, 2024
@sonarqubecloud

sonarqubecloud Bot commented Jun 2, 2024

Copy link
Copy Markdown

Quality Gate Passed Quality Gate passed

Issues
0 New issues
0 Accepted issues

Measures
0 Security Hotspots
No data about Coverage
0.0% Duplication on New Code

See analysis details on SonarCloud

@haslinghuis
haslinghuis marked this pull request as draft June 3, 2024 00:20
@blckmn
blckmn self-requested a review October 1, 2024 21:34
@sonarqubecloud

sonarqubecloud Bot commented Jan 5, 2025

Copy link
Copy Markdown

@haslinghuis haslinghuis removed the status in 2025.12.0 Nov 12, 2025
@haslinghuis haslinghuis removed this from 2025.12.0 Nov 12, 2025
@haslinghuis haslinghuis moved this to App in 2026.6.0 Nov 12, 2025
@haslinghuis haslinghuis modified the milestones: 2025.12, 2026.6 Nov 12, 2025
@coderabbitai

coderabbitai Bot commented Mar 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f44b6f0-a274-4e64-92eb-d8e271bcde41

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

🎉 Do you want to test this code? 🎉

⚠️ CAUTION: The build may be unstable and result in corrupted configurations or data loss. Use only for testing! ⚠️

@haslinghuis haslinghuis removed this from 2026.6.0 Jun 27, 2026
@haslinghuis haslinghuis moved this to App in 2026.12 Jun 27, 2026
@haslinghuis

Copy link
Copy Markdown
Member Author

Resolved in #5525

@github-project-automation github-project-automation Bot moved this from App to Done in 2026.12 Sep 15, 2026

This branch was previously deployed

1 inactive deployment
master — 9c22f04b Deployed Mar 25, 2026 by haslinghuis via Deploy / Deploy to Cloudflare Pages #1607
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants