tr]:last:border-b-0",
59 | // className
60 | // )}
61 | // {...props}
62 | // />
63 | // );
64 | // };
65 |
66 | const TableRow = function TableRow({
67 | className,
68 | ...props
69 | }: React.ComponentProps<"tr">) {
70 | return (
71 |
76 | );
77 | };
78 |
79 | const TableHead = function TableHead({
80 | className,
81 | ...props
82 | }: React.ComponentProps<"th">) {
83 | return (
84 | [role=checkbox]]:translate-y-[2px]",
88 | className
89 | )}
90 | {...props}
91 | />
92 | );
93 | };
94 |
95 | const TableCell = function TableCell({
96 | className,
97 | ...props
98 | }: React.ComponentProps<"td">) {
99 | return (
100 | [role=checkbox]]:translate-y-[2px]",
105 | className
106 | )}
107 | {...props}
108 | />
109 | );
110 | };
111 |
112 | // function TableCaption({
113 | // className,
114 | // ...props
115 | // }: React.ComponentProps<"caption">) {
116 | // return (
117 | //
122 | // )
123 | // }
124 |
125 | export {
126 | Table,
127 | TableHeader,
128 | TableBody,
129 | // TableFooter,
130 | TableHead,
131 | TableRow,
132 | TableCell
133 | // TableCaption,
134 | };
135 |
--------------------------------------------------------------------------------
/src/hooks/useGeminiAI.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 |
4 | export function useGeminiAI() {
5 | const geminiApiKey = useDatabaseStore((state) => state.geminiApiKey);
6 | const tablesSchema = useDatabaseStore((state) => state.tablesSchema);
7 | const customQuery = useDatabaseStore((state) => state.customQuery);
8 | const setCustomQuery = useDatabaseStore((state) => state.setCustomQuery);
9 | const setErrorMessage = useDatabaseStore((state) => state.setErrorMessage);
10 | const setIsAiLoading = useDatabaseStore((state) => state.setIsAiLoading);
11 | const isAiLoading = useDatabaseStore((state) => state.isAiLoading);
12 |
13 | const generateSqlQuery = useCallback(async () => {
14 | if (!geminiApiKey) {
15 | setErrorMessage("Gemini API key not set.");
16 | return;
17 | }
18 |
19 | if (!customQuery || !customQuery.startsWith("/ai ")) {
20 | return;
21 | }
22 |
23 | setIsAiLoading(true);
24 | setErrorMessage(null);
25 |
26 | try {
27 | const response = await fetch(
28 | `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${geminiApiKey}`,
29 | {
30 | method: "POST",
31 | headers: {
32 | "Content-Type": "application/json"
33 | },
34 | body: JSON.stringify({
35 | contents: [
36 | {
37 | parts: [
38 | {
39 | text: `You are an expert SQLite assistant. Your role is to generate a single, valid SQLite query based on the provided database schema and user prompt.
40 | **Instructions:**
41 | 1. **Analyze the Schema:** Carefully review the following table and index information to understand the database structure.
42 | 2. **Interpret the Prompt:** Understand the user's request and translate it into a precise SQLite query.
43 | 3. **Generate SQL Only:** Your output must be **only** the raw SQL query. Do not include any other text, explanations, or markdown formatting.
44 | 4. **Use SQL Comments for Notes:** If you need to add any notes or clarifications, use SQL comments (e.g., -- your note here).
45 |
46 | **Database Schema:**
47 | \`\`\`json
48 | ${JSON.stringify(tablesSchema, null, 2)}
49 | \`\`\`
50 | **User Prompt:**
51 | ${customQuery.substring(4)}`
52 | }
53 | ]
54 | }
55 | ]
56 | })
57 | }
58 | );
59 |
60 | const data = await response.json();
61 | let sqlQuery = data.candidates[0].content.parts[0].text;
62 | const sqlMatch = sqlQuery.match(/```(?:sql|sqlite)\n([\s\S]*?)\n```/);
63 | if (sqlMatch && sqlMatch[1]) {
64 | sqlQuery = sqlMatch[1].trim();
65 | } else {
66 | sqlQuery = sqlQuery.trim();
67 | }
68 | setCustomQuery(sqlQuery);
69 | } catch (error) {
70 | console.error("Error calling Gemini API:", error);
71 | setErrorMessage("Error calling Gemini API.");
72 | } finally {
73 | setIsAiLoading(false);
74 | }
75 | }, [
76 | geminiApiKey,
77 | customQuery,
78 | tablesSchema,
79 | setCustomQuery,
80 | setErrorMessage,
81 | setIsAiLoading
82 | ]);
83 |
84 | return { generateSqlQuery, isAiLoading };
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/structureTab/SchemaTree.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo, useCallback } from "react";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 | import { useSchemaStore } from "@/store/useSchemaStore";
4 |
5 | import type { TableSchema } from "@/types";
6 |
7 | import TablesSection from "./TablesSection";
8 | import IndexesSection from "./IndexesSection";
9 | import SchemaSearch from "./SchemaSearch";
10 |
11 | const SchemaTree = () => {
12 | const [filter, setFilter] = useState("");
13 | const [expandedTableSection, setExpandedTableSection] = useState(true);
14 |
15 | const expandedIndexSection = useSchemaStore(
16 | (state) => state.expandedIndexSection
17 | );
18 | const toggleExpandedIndexSection = useSchemaStore(
19 | (state) => state.toggleExpandedIndexSection
20 | );
21 |
22 | const tablesSchema = useDatabaseStore((state) => state.tablesSchema);
23 | const indexesSchema = useDatabaseStore((state) => state.indexesSchema);
24 |
25 | const expandedTables = useSchemaStore((state) => state.expandedTables);
26 | const toggleTable = useSchemaStore((state) => state.toggleTable);
27 | const setExpandedTables = useSchemaStore((state) => state.setExpandedTables);
28 |
29 | const toggleTableSection = useCallback(() => {
30 | setExpandedTableSection((prev) => !prev);
31 | }, []);
32 |
33 | const expandAllTables = useCallback(
34 | (tablesSchema: TableSchema) => {
35 | setExpandedTables(Object.keys(tablesSchema));
36 | setExpandedTableSection(true);
37 | },
38 | [setExpandedTables]
39 | );
40 |
41 | const collapseAllTables = useCallback(() => {
42 | setExpandedTables([]);
43 | setExpandedTableSection(false);
44 | }, [setExpandedTables]);
45 |
46 | const filteredTables = useMemo(() => {
47 | if (!filter) return tablesSchema;
48 |
49 | const filtered: TableSchema = {};
50 | for (const [tableName, tableData] of Object.entries(tablesSchema)) {
51 | if (tableName.toLowerCase().includes(filter.toLowerCase())) {
52 | filtered[tableName] = tableData;
53 | }
54 | }
55 | return filtered;
56 | }, [tablesSchema, filter]);
57 |
58 | const filteredIndexes = useMemo(() => {
59 | if (!filter) return indexesSchema;
60 |
61 | return indexesSchema.filter(
62 | (index) =>
63 | index.name.toLowerCase().includes(filter.toLowerCase()) ||
64 | index.tableName.toLowerCase().includes(filter.toLowerCase())
65 | );
66 | }, [indexesSchema, filter]);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
83 | {indexesSchema.length > 0 && (
84 |
89 | )}
90 |
91 |
92 | );
93 | };
94 |
95 | export default SchemaTree;
96 |
--------------------------------------------------------------------------------
/src/components/structureTab/TablesSection.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback, useState } from "react";
2 |
3 | import type { TableSchema } from "@/types";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import TableItem from "./TableItem";
7 | import SectionHeader from "./common/SectionHeader";
8 |
9 | import {
10 | DatabaseIcon,
11 | ChevronsUpDownIcon,
12 | ChevronsDownUpIcon
13 | } from "lucide-react";
14 |
15 | interface TablesSectionProps {
16 | tablesSchema: TableSchema;
17 | expandAllTables: (tablesSchema: TableSchema) => void;
18 | collapseAllTables: () => void;
19 | expandedTableSection: boolean;
20 | toggleTableSection: () => void;
21 | expandedTables: string[];
22 | toggleTable: (tableName: string) => void;
23 | }
24 |
25 | const TablesSection = memo(
26 | ({
27 | tablesSchema,
28 | expandAllTables,
29 | collapseAllTables,
30 | expandedTableSection,
31 | toggleTableSection,
32 | expandedTables,
33 | toggleTable
34 | }: TablesSectionProps) => {
35 | const [isExpanded, setIsExpanded] = useState(false);
36 |
37 | const handleExpandAll = useCallback(
38 | (e: React.MouseEvent) => {
39 | e.stopPropagation();
40 | expandAllTables(tablesSchema);
41 | setIsExpanded(true);
42 | },
43 | [expandAllTables, tablesSchema]
44 | );
45 |
46 | const handleCollapseAll = useCallback(
47 | (e: React.MouseEvent) => {
48 | e.stopPropagation();
49 | collapseAllTables();
50 | setIsExpanded(false);
51 | },
52 | [collapseAllTables]
53 | );
54 |
55 | const expandControls = expandedTableSection && (
56 |
57 | {isExpanded ? (
58 |
65 |
66 |
67 | ) : (
68 |
75 |
76 |
77 | )}
78 |
79 | );
80 |
81 | return (
82 |
83 | }
87 | onToggle={toggleTableSection}
88 | >
89 | {expandControls}
90 |
91 |
92 | {expandedTableSection && (
93 |
94 | {Object.entries(tablesSchema).map(([tableName, tableData]) => (
95 |
102 | ))}
103 |
104 | )}
105 |
106 | );
107 | }
108 | );
109 |
110 | export default TablesSection;
111 |
--------------------------------------------------------------------------------
/src/components/browseTab/ActionButtons.tsx:
--------------------------------------------------------------------------------
1 | import { useDatabaseStore } from "@/store/useDatabaseStore";
2 | import usePanelManager from "@/hooks/usePanel";
3 | import useDatabaseWorker from "@/hooks/useWorker";
4 |
5 | import type { Filters, Sorters } from "@/types";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import ActionsDropdown from "./ActionsDropdown";
9 |
10 | import { FilterXIcon, FolderOutputIcon, ListRestartIcon } from "lucide-react";
11 |
12 | interface ActionButtonsProps {
13 | filters: Filters;
14 | sorters: Sorters;
15 | }
16 |
17 | function ActionButtons({ filters, sorters }: Readonly) {
18 | const setFilters = useDatabaseStore((state) => state.setFilters);
19 | const setSorters = useDatabaseStore((state) => state.setSorters);
20 | const { handleExport } = useDatabaseWorker();
21 | const { setSelectedRowObject } = usePanelManager();
22 |
23 | const hasFilters = filters != null;
24 | const hasSorters = sorters != null;
25 | const filterCount = hasFilters ? Object.keys(filters).length : 0;
26 | const sorterCount = hasSorters ? Object.keys(sorters).length : 0;
27 |
28 | return (
29 | <>
30 |
35 | {
40 | setFilters(null);
41 | setSelectedRowObject(null);
42 | }}
43 | disabled={!hasFilters}
44 | aria-label={
45 | hasFilters
46 | ? `Clear ${filterCount} active filter${filterCount !== 1 ? "s" : ""}`
47 | : "No filters to clear"
48 | }
49 | aria-describedby="clear-filters-description"
50 | >
51 |
52 | Clear filters
53 |
54 |
55 | {hasFilters
56 | ? `${filterCount} filter${filterCount !== 1 ? "s" : ""} currently applied`
57 | : "No filters currently applied"}
58 |
59 |
60 | {
65 | setSorters(null);
66 | setSelectedRowObject(null);
67 | }}
68 | disabled={!hasSorters}
69 | aria-label={
70 | hasSorters
71 | ? `Reset ${sorterCount} active sort${sorterCount !== 1 ? "s" : ""}`
72 | : "No sorting to reset"
73 | }
74 | aria-describedby="reset-sorting-description"
75 | >
76 |
77 | Reset sorting
78 |
79 |
80 | {hasSorters
81 | ? `${sorterCount} column${sorterCount !== 1 ? "s" : ""} currently sorted`
82 | : "No sorting currently applied"}
83 |
84 |
85 | handleExport("table")}
90 | aria-label="Export entire table as CSV file"
91 | >
92 |
93 | Export table
94 |
95 |
96 |
105 | >
106 | );
107 | }
108 |
109 | export default ActionButtons;
110 |
--------------------------------------------------------------------------------
/src/components/browseTab/BrowseTab.tsx:
--------------------------------------------------------------------------------
1 | import { useDatabaseStore } from "@/store/useDatabaseStore";
2 | import { usePanelStore } from "@/store/usePanelStore";
3 | import usePanelManager from "@/hooks/usePanel";
4 |
5 | import {
6 | ResizableHandle,
7 | ResizablePanel,
8 | ResizablePanelGroup
9 | } from "@/components/ui/resizable";
10 | import SchemaTree from "@/components/structureTab/SchemaTree";
11 | import TableSelector from "./TableSelector";
12 | import EditSection from "./EditSection";
13 | import DataTable from "./DataTable";
14 | import PaginationControls from "./PaginationControls";
15 | import ActionButtons from "./ActionButtons";
16 |
17 | import { LoaderCircleIcon } from "lucide-react";
18 |
19 | function BrowseDataTab() {
20 | const filters = useDatabaseStore((state) => state.filters);
21 | const sorters = useDatabaseStore((state) => state.sorters);
22 | const isDataLoading = useDatabaseStore((state) => state.isDataLoading);
23 | const isDatabaseLoading = useDatabaseStore(
24 | (state) => state.isDatabaseLoading
25 | );
26 |
27 | const dataPanelSize = usePanelStore((state) => state.dataPanelSize);
28 | const schemaPanelSize = usePanelStore((state) => state.schemaPanelSize);
29 | const setDataPanelSize = usePanelStore((state) => state.setDataPanelSize);
30 | const setSchemaPanelSize = usePanelStore((state) => state.setSchemaPanelSize);
31 |
32 | const { isEditing } = usePanelManager();
33 |
34 | return (
35 |
36 |
41 |
42 |
43 | {(isDataLoading || isDatabaseLoading) && (
44 |
50 |
54 | Loading data
55 |
56 | )}
57 |
58 |
59 |
60 |
61 | {/* Left Panel - Data Table */}
62 |
67 |
74 |
75 |
76 |
77 |
78 |
84 |
89 | {/* TODO: Move this to a separate component as an edit section */}
90 |
93 |
96 |
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
104 | export default BrowseDataTab;
105 |
--------------------------------------------------------------------------------
/src/components/browseTab/ActionsDropdown.tsx:
--------------------------------------------------------------------------------
1 | import usePanelManager from "@/hooks/usePanel";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 |
4 | import type { exportTypes, Filters, Sorters } from "@/types";
5 |
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuTrigger
11 | } from "@/components/ui/dropdown-menu";
12 | import { Button } from "@/components/ui/button";
13 |
14 | import {
15 | ChevronDownIcon,
16 | FilterXIcon,
17 | FolderOutputIcon,
18 | ListRestartIcon,
19 | PlusIcon
20 | } from "lucide-react";
21 |
22 | interface ActionDropdownProps {
23 | setFilters: (filters: Filters) => void;
24 | setSorters: (sorters: Sorters) => void;
25 | filters: Filters;
26 | sorters: Sorters;
27 | handleExport: (exportType: exportTypes) => void;
28 | }
29 |
30 | function ActionsDropdown({
31 | setFilters,
32 | setSorters,
33 | filters,
34 | sorters,
35 | handleExport
36 | }: Readonly) {
37 | const { isInserting, handleInsert, setSelectedRowObject } = usePanelManager();
38 | const isView = useDatabaseStore(
39 | (state) => state.tablesSchema[state.currentTable!]?.type === "view"
40 | );
41 |
42 | return (
43 |
44 |
45 |
46 | Actions
47 |
48 |
49 |
50 |
51 | {
56 | setFilters(null);
57 | setSelectedRowObject(null);
58 | }}
59 | disabled={filters == null}
60 | title="Clear applied filters"
61 | >
62 |
63 | Clear filters
64 |
65 |
66 |
67 | {
72 | setSorters(null);
73 | setSelectedRowObject(null);
74 | }}
75 | disabled={sorters == null}
76 | title="Reset sorting"
77 | >
78 |
79 | Reset sorting
80 |
81 |
82 |
83 |
91 |
92 | Insert row
93 |
94 |
95 |
96 | handleExport("table")}
101 | title="Export the current table as CSV"
102 | >
103 |
104 | Export table
105 |
106 |
107 |
108 | handleExport("current")}
113 | title="Export the current data as CSV"
114 | >
115 |
116 | Export data
117 |
118 |
119 |
120 |
121 | );
122 | }
123 |
124 | export default ActionsDropdown;
125 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
14 | SQLite Online
15 |
16 |
17 |
22 |
28 |
34 |
40 |
46 |
47 |
51 |
55 |
56 |
57 |
58 |
62 |
63 |
64 |
65 |
66 |
67 |
71 |
72 |
73 |
74 |
80 |
85 |
86 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
JavaScript Required
106 |
107 | We're sorry, but this application requires JavaScript to function
108 | correctly. Please enable JavaScript in your browser and reload the
109 | page.
110 |
111 |
112 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { XIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function Dialog({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | // function DialogTrigger({
14 | // ...props
15 | // }: React.ComponentProps) {
16 | // return ;
17 | // }
18 |
19 | function DialogPortal({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | // function DialogClose({
26 | // ...props
27 | // }: React.ComponentProps) {
28 | // return ;
29 | // }
30 |
31 | function DialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function DialogContent({
48 | className,
49 | children,
50 | ...props
51 | }: React.ComponentProps) {
52 | return (
53 |
54 |
55 |
63 | {children}
64 |
65 |
66 | Close
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
74 | return (
75 |
80 | );
81 | }
82 |
83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
93 | );
94 | }
95 |
96 | function DialogTitle({
97 | className,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
106 | );
107 | }
108 |
109 | function DialogDescription({
110 | className,
111 | ...props
112 | }: React.ComponentProps) {
113 | return (
114 |
119 | );
120 | }
121 |
122 | export {
123 | Dialog,
124 | // DialogClose,
125 | DialogContent,
126 | DialogDescription,
127 | DialogFooter,
128 | DialogHeader,
129 | // DialogOverlay,
130 | // DialogPortal,
131 | DialogTitle
132 | // DialogTrigger
133 | };
134 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: ["main"]
17 | pull_request:
18 | branches: ["main"]
19 | schedule:
20 | - cron: "29 20 * * 2"
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze (${{ matrix.language }})
25 | # Runner size impacts CodeQL analysis time. To learn more, please see:
26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
27 | # - https://gh.io/supported-runners-and-hardware-resources
28 | # - https://gh.io/using-larger-runners (GitHub.com only)
29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
32 | permissions:
33 | # required for all workflows
34 | security-events: write
35 |
36 | # required to fetch internal or private CodeQL packs
37 | packages: read
38 |
39 | # only required for workflows in private repositories
40 | actions: read
41 | contents: read
42 |
43 | strategy:
44 | fail-fast: false
45 | matrix:
46 | include:
47 | - language: javascript-typescript
48 | build-mode: none
49 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
50 | # Use `c-cpp` to analyze code written in C, C++ or both
51 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
52 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
53 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
54 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
55 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
56 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
57 | steps:
58 | - name: Checkout repository
59 | uses: actions/checkout@v4
60 |
61 | # Initializes the CodeQL tools for scanning.
62 | - name: Initialize CodeQL
63 | uses: github/codeql-action/init@v3
64 | with:
65 | languages: ${{ matrix.language }}
66 | build-mode: ${{ matrix.build-mode }}
67 | # If you wish to specify custom queries, you can do so here or in a config file.
68 | # By default, queries listed here will override any specified in a config file.
69 | # Prefix the list here with "+" to use these queries and those in the config file.
70 |
71 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
72 | # queries: security-extended,security-and-quality
73 |
74 | # If the analyze step fails for one of the languages you are analyzing with
75 | # "We were unable to automatically build your code", modify the matrix above
76 | # to set the build mode to "manual" for that language. Then modify this step
77 | # to build your code.
78 | # ℹ️ Command-line programs to run using the OS shell.
79 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
80 | - if: matrix.build-mode == 'manual'
81 | shell: bash
82 | run: |
83 | echo 'If you are using a "manual" build mode for one or more of the' \
84 | 'languages you are analyzing, replace this with the commands to build' \
85 | 'your code, for example:'
86 | echo ' make bootstrap'
87 | echo ' make release'
88 | exit 1
89 |
90 | - name: Perform CodeQL Analysis
91 | uses: github/codeql-action/analyze@v3
92 | with:
93 | category: "/language:${{matrix.language}}"
94 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { QueryExecResult, SqlValue } from "sql.js";
2 |
3 | export type TableSchema = {
4 | [tableName: string]: {
5 | primaryKey: "_rowid_" | string | null;
6 | schema: TableSchemaRow[];
7 | type: "table" | "view";
8 | };
9 | };
10 |
11 | export type TableSchemaRow = {
12 | name: string; // Column name
13 | cid: number;
14 | type: string | null;
15 | dflt_value: string;
16 | IsNullable: boolean;
17 | isPrimaryKey: boolean;
18 | isForeignKey: boolean;
19 | };
20 |
21 | export type IndexSchema = {
22 | name: string;
23 | tableName: string;
24 | };
25 |
26 | export type Sorters = Record | null;
27 | export type Filters = Record | null;
28 |
29 | export type EditTypes = "insert" | "update" | "delete";
30 | export type exportTypes = "table" | "current" | "custom";
31 |
32 | // --- WORKER MESSAGES --- //
33 | interface InitEvent {
34 | action: "init";
35 | payload: undefined;
36 | }
37 |
38 | interface OpenFileEvent {
39 | action: "openFile";
40 | payload: {
41 | file: ArrayBuffer;
42 | };
43 | }
44 |
45 | interface RefreshEvent {
46 | action: "refresh";
47 | payload: {
48 | currentTable: string;
49 | limit: number;
50 | offset: number;
51 | filters: Filters;
52 | sorters: Sorters;
53 | };
54 | }
55 |
56 | interface ExecEvent {
57 | action: "exec";
58 | payload: {
59 | query: string;
60 | currentTable: string;
61 | limit: number;
62 | offset: number;
63 | filters: Filters;
64 | sorters: Sorters;
65 | };
66 | }
67 |
68 | interface ExecBatchEvent {
69 | action: "execBatch";
70 | payload: {
71 | queries: string[];
72 | currentTable: string;
73 | limit: number;
74 | offset: number;
75 | filters: Filters;
76 | sorters: Sorters;
77 | };
78 | }
79 |
80 | interface GetTableDataEvent {
81 | action: "getTableData";
82 | payload: {
83 | currentTable: string;
84 | limit: number;
85 | offset: number;
86 | filters: Filters;
87 | sorters: Sorters;
88 | };
89 | }
90 |
91 | interface DownloadEvent {
92 | action: "download";
93 | payload: undefined;
94 | }
95 |
96 | interface UpdateEvent {
97 | action: "update";
98 | payload: {
99 | table: string;
100 | columns: string[];
101 | values: SqlValue[];
102 | primaryValue: SqlValue;
103 | };
104 | }
105 |
106 | interface DeleteEvent {
107 | action: "delete";
108 | payload: {
109 | table: string;
110 | primaryValue: SqlValue;
111 | };
112 | }
113 |
114 | interface InsertEvent {
115 | action: "insert";
116 | payload: {
117 | table: string;
118 | columns: string[];
119 | values: SqlValue[];
120 | };
121 | }
122 |
123 | interface ExportEvent {
124 | action: "export";
125 | payload: {
126 | table: string;
127 | filters: Filters;
128 | sorters: Sorters;
129 | limit: number;
130 | offset: number;
131 | customQuery: string;
132 | exportType: exportTypes;
133 | };
134 | }
135 |
136 | export type WorkerEvent =
137 | | InitEvent
138 | | OpenFileEvent
139 | | RefreshEvent
140 | | ExecEvent
141 | | ExecBatchEvent
142 | | GetTableDataEvent
143 | | DownloadEvent
144 | | UpdateEvent
145 | | DeleteEvent
146 | | InsertEvent
147 | | ExportEvent;
148 |
149 | // --- WORKER RESPONSE --- //
150 | interface InitCompleteResponse {
151 | action: "initComplete";
152 | payload: {
153 | tableSchema: TableSchema;
154 | indexSchema: IndexSchema[];
155 | currentTable: string;
156 | };
157 | }
158 |
159 | interface QueryCompleteResponse {
160 | action: "queryComplete";
161 | payload: {
162 | results?: QueryExecResult[];
163 | maxSize: number;
164 | };
165 | }
166 |
167 | interface CustomQueryCompleteResponse {
168 | action: "customQueryComplete";
169 | payload: {
170 | results: QueryExecResult[];
171 | };
172 | }
173 |
174 | interface UpdateInstanceResponse {
175 | action: "updateInstance";
176 | payload: {
177 | tableSchema: TableSchema;
178 | indexSchema: IndexSchema[];
179 | };
180 | }
181 |
182 | interface UpdateCompleteResponse {
183 | action: "updateComplete";
184 | payload: {
185 | type: EditTypes;
186 | };
187 | }
188 |
189 | interface InsertCompleteResponse {
190 | action: "insertComplete";
191 | }
192 |
193 | interface DownloadCompleteResponse {
194 | action: "downloadComplete";
195 | payload: {
196 | bytes: ArrayBuffer;
197 | };
198 | }
199 |
200 | interface ExportCompleteResponse {
201 | action: "exportComplete";
202 | payload: {
203 | results: string;
204 | };
205 | }
206 |
207 | interface QueryErrorResponse {
208 | action: "queryError";
209 | payload: {
210 | error: {
211 | message: string;
212 | isCustomQueryError: boolean;
213 | };
214 | };
215 | }
216 |
217 | // Union type for all possible worker responses
218 | export type WorkerResponseEvent =
219 | | InitCompleteResponse
220 | | QueryCompleteResponse
221 | | CustomQueryCompleteResponse
222 | | UpdateInstanceResponse
223 | | UpdateCompleteResponse
224 | | InsertCompleteResponse
225 | | DownloadCompleteResponse
226 | | ExportCompleteResponse
227 | | QueryErrorResponse;
228 |
--------------------------------------------------------------------------------
/src/store/useDatabaseStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | import type { TableSchema, IndexSchema, Filters, Sorters } from "@/types";
4 | import type { SqlValue } from "sql.js";
5 | import SecureStorage from "@/lib/secureStorage";
6 |
7 | interface DatabaseState {
8 | tablesSchema: TableSchema;
9 | indexesSchema: IndexSchema[];
10 | currentTable: string | null;
11 | data: SqlValue[][] | null;
12 | columns: string[] | null;
13 | maxSize: number;
14 | isDatabaseLoading: boolean;
15 | isDataLoading: boolean;
16 | errorMessage: string | null;
17 | filters: Filters;
18 | sorters: Sorters;
19 | limit: number;
20 | offset: number;
21 | customQuery?: string;
22 | customQueryObject: {
23 | data: SqlValue[][];
24 | columns: string[];
25 | } | null;
26 | geminiApiKey: string | null;
27 | isAiLoading: boolean;
28 | }
29 |
30 | interface DatabaseActions {
31 | setTablesSchema: (schema: TableSchema) => void;
32 | setIndexesSchema: (schema: IndexSchema[]) => void;
33 | setCurrentTable: (table: string | null) => void;
34 | setData: (data: SqlValue[][] | null) => void;
35 | setColumns: (columns: string[] | null) => void;
36 | setMaxSize: (size: number) => void;
37 | setIsDatabaseLoading: (loading: boolean) => void;
38 | setIsDataLoading: (loading: boolean) => void;
39 | setErrorMessage: (message: string | null) => void;
40 | setFilters: (filters: Filters) => void;
41 | setSorters: (sorters: Sorters) => void;
42 | setLimit: (limit: number) => void;
43 | setOffset: (offset: number) => void;
44 | setCustomQuery: (query: string) => void;
45 | setCustomQueryObject: (
46 | obj: { data: SqlValue[][]; columns: string[] } | null
47 | ) => void;
48 | setGeminiApiKey: (key: string | null) => Promise;
49 | setIsAiLoading: (loading: boolean) => void;
50 | resetPagination: () => void;
51 | initializeApiKey: () => Promise;
52 | }
53 |
54 | type DatabaseStore = DatabaseState & DatabaseActions;
55 |
56 | export const useDatabaseStore = create((set) => ({
57 | // --- State ---
58 | tablesSchema: {} as TableSchema,
59 | indexesSchema: [],
60 | currentTable: null,
61 | data: null,
62 | columns: null,
63 | maxSize: 1,
64 | isDatabaseLoading: false,
65 | isDataLoading: false,
66 | errorMessage: null,
67 | filters: null,
68 | sorters: null,
69 | limit: 50,
70 | offset: 0,
71 | customQuery: undefined,
72 | customQueryObject: null,
73 | geminiApiKey: null, // Will be initialized asynchronously
74 | isAiLoading: false,
75 |
76 | // --- Actions ---
77 | setTablesSchema: (schema) => set({ tablesSchema: schema }),
78 | setIndexesSchema: (schema) => set({ indexesSchema: schema }),
79 | setCurrentTable: (table) => set({ currentTable: table }),
80 | setData: (data) => set({ data }),
81 | setColumns: (columns) => set({ columns }),
82 | setMaxSize: (size) => set({ maxSize: size }),
83 | setIsDatabaseLoading: (loading) => set({ isDatabaseLoading: loading }),
84 | setIsDataLoading: (loading) => set({ isDataLoading: loading }),
85 | setErrorMessage: (message) => set({ errorMessage: message }),
86 | setFilters: (filters) => set({ filters }),
87 | setSorters: (sorters) => set({ sorters }),
88 | setLimit: (limit) => set({ limit }),
89 | setOffset: (offset) => set({ offset }),
90 | setCustomQuery: (query) => set({ customQuery: query }),
91 | setCustomQueryObject: (obj) => set({ customQueryObject: obj }),
92 | setGeminiApiKey: async (key) => {
93 | set({ geminiApiKey: key });
94 | try {
95 | if (key) {
96 | await SecureStorage.setItem("geminiApiKey", key);
97 | } else {
98 | SecureStorage.removeItem("geminiApiKey");
99 | }
100 | } catch (error) {
101 | console.error("Failed to store API key securely:", error);
102 | // Fallback to regular localStorage with warning
103 | console.warn("Falling back to localStorage for API key storage");
104 | if (key) {
105 | localStorage.setItem("geminiApiKey", key);
106 | } else {
107 | localStorage.removeItem("geminiApiKey");
108 | }
109 | }
110 | },
111 | setIsAiLoading: (loading) => set({ isAiLoading: loading }),
112 | resetPagination: () => set({ offset: 0 }),
113 | initializeApiKey: async () => {
114 | try {
115 | const key = await SecureStorage.getItem("geminiApiKey");
116 | if (key) {
117 | set({ geminiApiKey: key });
118 | } else {
119 | // Check for legacy localStorage key and migrate
120 | const legacyKey =
121 | typeof window !== "undefined"
122 | ? localStorage.getItem("geminiApiKey")
123 | : null;
124 | if (legacyKey) {
125 | await SecureStorage.setItem("geminiApiKey", legacyKey);
126 | localStorage.removeItem("geminiApiKey");
127 | set({ geminiApiKey: legacyKey });
128 | }
129 | }
130 | } catch (error) {
131 | console.error("Failed to initialize API key:", error);
132 | // Fallback to localStorage
133 | const key =
134 | typeof window !== "undefined"
135 | ? localStorage.getItem("geminiApiKey")
136 | : null;
137 | set({ geminiApiKey: key });
138 | }
139 | }
140 | }));
141 |
--------------------------------------------------------------------------------
/src/lib/queryCache.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple LRU cache for database query results
3 | * Helps improve performance by caching frequently accessed data
4 | */
5 |
6 | interface CacheEntry {
7 | data: T;
8 | timestamp: number;
9 | accessCount: number;
10 | lastAccessed: number;
11 | }
12 |
13 | class QueryCache {
14 | private cache = new Map>();
15 | private maxSize: number;
16 | private maxAge: number; // in milliseconds
17 |
18 | constructor(maxSize = 100, maxAge = 5 * 60 * 1000) {
19 | // 5 minutes default
20 | this.maxSize = maxSize;
21 | this.maxAge = maxAge;
22 | }
23 |
24 | /**
25 | * Generate cache key from query parameters
26 | */
27 | private generateKey(
28 | table: string,
29 | limit: number,
30 | offset: number,
31 | filters?: Record | null,
32 | sorters?: Record | null
33 | ): string {
34 | const filterStr = filters ? JSON.stringify(filters) : "";
35 | const sorterStr = sorters ? JSON.stringify(sorters) : "";
36 | return `${table}:${limit}:${offset}:${filterStr}:${sorterStr}`;
37 | }
38 |
39 | /**
40 | * Get cached result if available and not expired
41 | */
42 | get(
43 | table: string,
44 | limit: number,
45 | offset: number,
46 | filters?: Record | null,
47 | sorters?: Record | null
48 | ): T | null {
49 | const key = this.generateKey(table, limit, offset, filters, sorters);
50 | const entry = this.cache.get(key);
51 |
52 | if (!entry) {
53 | return null;
54 | }
55 |
56 | const now = Date.now();
57 |
58 | // Check if entry is expired
59 | if (now - entry.timestamp > this.maxAge) {
60 | this.cache.delete(key);
61 | return null;
62 | }
63 |
64 | // Update access statistics
65 | entry.accessCount++;
66 | entry.lastAccessed = now;
67 |
68 | return entry.data;
69 | }
70 |
71 | /**
72 | * Store result in cache
73 | */
74 | set(
75 | table: string,
76 | limit: number,
77 | offset: number,
78 | data: T,
79 | filters?: Record | null,
80 | sorters?: Record | null
81 | ): void {
82 | const key = this.generateKey(table, limit, offset, filters, sorters);
83 | const now = Date.now();
84 |
85 | // If cache is full, remove least recently used entry
86 | if (this.cache.size >= this.maxSize) {
87 | this.evictLRU();
88 | }
89 |
90 | this.cache.set(key, {
91 | data,
92 | timestamp: now,
93 | accessCount: 1,
94 | lastAccessed: now
95 | });
96 | }
97 |
98 | /**
99 | * Invalidate cache entries for a specific table
100 | */
101 | invalidateTable(table: string): void {
102 | const keysToDelete: string[] = [];
103 |
104 | for (const key of this.cache.keys()) {
105 | if (key.startsWith(`${table}:`)) {
106 | keysToDelete.push(key);
107 | }
108 | }
109 |
110 | keysToDelete.forEach((key) => this.cache.delete(key));
111 | }
112 |
113 | /**
114 | * Clear all cache entries
115 | */
116 | clear(): void {
117 | this.cache.clear();
118 | }
119 |
120 | /**
121 | * Remove expired entries
122 | */
123 | cleanup(): void {
124 | const now = Date.now();
125 | const keysToDelete: string[] = [];
126 |
127 | for (const [key, entry] of this.cache.entries()) {
128 | if (now - entry.timestamp > this.maxAge) {
129 | keysToDelete.push(key);
130 | }
131 | }
132 |
133 | keysToDelete.forEach((key) => this.cache.delete(key));
134 | }
135 |
136 | /**
137 | * Evict least recently used entry
138 | */
139 | private evictLRU(): void {
140 | let oldestKey: string | null = null;
141 | let oldestTime = Date.now();
142 |
143 | for (const [key, entry] of this.cache.entries()) {
144 | if (entry.lastAccessed < oldestTime) {
145 | oldestTime = entry.lastAccessed;
146 | oldestKey = key;
147 | }
148 | }
149 |
150 | if (oldestKey) {
151 | this.cache.delete(oldestKey);
152 | }
153 | }
154 |
155 | /**
156 | * Get cache statistics
157 | */
158 | getStats(): {
159 | size: number;
160 | maxSize: number;
161 | hitRate: number;
162 | entries: Array<{
163 | key: string;
164 | accessCount: number;
165 | age: number;
166 | }>;
167 | } {
168 | const now = Date.now();
169 | const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({
170 | key,
171 | accessCount: entry.accessCount,
172 | age: now - entry.timestamp
173 | }));
174 |
175 | // Calculate hit rate (simplified - would need request tracking for accurate rate)
176 | const totalAccesses = entries.reduce(
177 | (sum, entry) => sum + entry.accessCount,
178 | 0
179 | );
180 | const hitRate = entries.length > 0 ? totalAccesses / entries.length : 0;
181 |
182 | return {
183 | size: this.cache.size,
184 | maxSize: this.maxSize,
185 | hitRate,
186 | entries
187 | };
188 | }
189 | }
190 |
191 | // Create singleton instance for table data caching
192 | export const tableDataCache = new QueryCache();
193 |
194 | // Create singleton instance for custom query caching
195 | export const customQueryCache = new QueryCache(50, 2 * 60 * 1000); // 2 minutes for custom queries
196 |
197 | export default QueryCache;
198 |
--------------------------------------------------------------------------------
/src/components/executeTab/CustomSQLTextarea.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useMemo } from "react";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 | import useTheme from "@/hooks/useTheme";
4 |
5 | import CodeMirror from "@uiw/react-codemirror";
6 | import { darcula } from "@uiw/codemirror-theme-darcula";
7 | import { sql, SQLite } from "@codemirror/lang-sql";
8 | import {
9 | autocompletion,
10 | type CompletionContext
11 | } from "@codemirror/autocomplete";
12 |
13 | // SQLlite Keywords used for autocompletion
14 | const SQLITE_KEYWORDS = [
15 | "ABORT",
16 | "ACTION",
17 | "ADD",
18 | "AFTER",
19 | "ALL",
20 | "ALTER",
21 | "ANALYZE",
22 | "AND",
23 | "AS",
24 | "ASC",
25 | "ATTACH",
26 | "AUTOINCREMENT",
27 | "BEFORE",
28 | "BEGIN",
29 | "BETWEEN",
30 | "BY",
31 | "CASCADE",
32 | "CASE",
33 | "CAST",
34 | "CHECK",
35 | "COLLATE",
36 | "COLUMN",
37 | "COMMIT",
38 | "CONFLICT",
39 | "CONSTRAINT",
40 | "CREATE",
41 | "CROSS",
42 | "CURRENT_DATE",
43 | "CURRENT_TIME",
44 | "CURRENT_TIMESTAMP",
45 | "DATABASE",
46 | "DEFAULT",
47 | "DEFERRABLE",
48 | "DEFERRED",
49 | "DELETE",
50 | "DESC",
51 | "DETACH",
52 | "DISTINCT",
53 | "DROP",
54 | "EACH",
55 | "ELSE",
56 | "END",
57 | "ESCAPE",
58 | "EXCEPT",
59 | "EXCLUSIVE",
60 | "EXISTS",
61 | "EXPLAIN",
62 | "FAIL",
63 | "FOR",
64 | "FOREIGN",
65 | "FROM",
66 | "FULL",
67 | "GLOB",
68 | "GROUP",
69 | "HAVING",
70 | "IF",
71 | "IGNORE",
72 | "IMMEDIATE",
73 | "IN",
74 | "INDEX",
75 | "INDEXED",
76 | "INITIALLY",
77 | "INNER",
78 | "INSERT",
79 | "INSTEAD",
80 | "INTERSECT",
81 | "INTO",
82 | "IS",
83 | "ISNULL",
84 | "JOIN",
85 | "KEY",
86 | "LEFT",
87 | "LIKE",
88 | "LIMIT",
89 | "MATCH",
90 | "NATURAL",
91 | "NO",
92 | "NOT",
93 | "NOTNULL",
94 | "NULL",
95 | "OF",
96 | "OFFSET",
97 | "ON",
98 | "OR",
99 | "ORDER",
100 | "OUTER",
101 | "PLAN",
102 | "PRAGMA",
103 | "PRIMARY",
104 | "QUERY",
105 | "RAISE",
106 | "RECURSIVE",
107 | "REFERENCES",
108 | "REGEXP",
109 | "REINDEX",
110 | "RELEASE",
111 | "RENAME",
112 | "REPLACE",
113 | "RESTRICT",
114 | "RIGHT",
115 | "ROLLBACK",
116 | "ROW",
117 | "SAVEPOINT",
118 | "SELECT",
119 | "SET",
120 | "TABLE",
121 | "TEMP",
122 | "TEMPORARY",
123 | "THEN",
124 | "TO",
125 | "TRANSACTION",
126 | "TRIGGER",
127 | "UNION",
128 | "UNIQUE",
129 | "UPDATE",
130 | "USING",
131 | "VACUUM",
132 | "VALUES",
133 | "VIEW",
134 | "VIRTUAL",
135 | "WHEN",
136 | "WHERE",
137 | "WITH",
138 | "WITHOUT"
139 | ];
140 |
141 | // SQLite functions used for autocompletion
142 | const BUILT_IN_FUNCTIONS = [
143 | "ABS",
144 | "ACOS",
145 | "ASIN",
146 | "ATAN",
147 | "CEIL",
148 | "COS",
149 | "EXP",
150 | "FLOOR",
151 | "HEX",
152 | "LENGTH",
153 | "LOG",
154 | "LOWER",
155 | "LTRIM",
156 | "OCT",
157 | "PI",
158 | "POW",
159 | "ROUND",
160 | "SIGN",
161 | "SIN",
162 | "SQRT",
163 | "TAN",
164 | "TRIM",
165 | "UPPER"
166 | ];
167 |
168 | function CustomSQLTextarea() {
169 | const { theme } = useTheme();
170 | const customQuery = useDatabaseStore((state) => state.customQuery);
171 | const setCustomQuery = useDatabaseStore((state) => state.setCustomQuery);
172 | const tablesSchema = useDatabaseStore((state) => state.tablesSchema);
173 |
174 | const { tableNames, columnNames } = useMemo(() => {
175 | const tableNames = Object.keys(tablesSchema);
176 | const columnNames = tableNames.flatMap((table) =>
177 | tablesSchema[table].schema.map((col) => col.name)
178 | );
179 | return { tableNames, columnNames };
180 | }, [tablesSchema]);
181 |
182 | const completionOptions = useMemo(() => {
183 | return [
184 | ...SQLITE_KEYWORDS.map((keyword) => ({
185 | label: keyword,
186 | type: "keyword"
187 | })),
188 | ...BUILT_IN_FUNCTIONS.map((fn) => ({
189 | label: fn,
190 | type: "function"
191 | })),
192 | ...tableNames.map((table) => ({
193 | label: table,
194 | type: "table"
195 | })),
196 | ...columnNames.map((column) => ({
197 | label: column,
198 | type: "column"
199 | }))
200 | ];
201 | }, [tableNames, columnNames]);
202 |
203 | const myCompletions = useCallback(
204 | (context: CompletionContext) => {
205 | const word = context.matchBefore(/\w*/);
206 | if (!word || (word.from === word.to && !context.explicit)) return null;
207 | return {
208 | from: word.from,
209 | to: word.to,
210 | options: completionOptions
211 | };
212 | },
213 | [completionOptions]
214 | );
215 |
216 | const handleChange = useCallback(
217 | (newValue: string) => {
218 | if (newValue !== customQuery) {
219 | setCustomQuery(newValue);
220 | }
221 | },
222 | [customQuery, setCustomQuery]
223 | );
224 |
225 | const extensions = useMemo(() => {
226 | if (customQuery && customQuery.startsWith("/ai ")) {
227 | return [autocompletion({ override: [myCompletions] })];
228 | } else {
229 | return [SQLite, sql(), autocompletion({ override: [myCompletions] })];
230 | }
231 | }, [myCompletions, customQuery]);
232 |
233 | return (
234 |
242 | );
243 | }
244 |
245 | export default CustomSQLTextarea;
246 |
--------------------------------------------------------------------------------
/src/components/common/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, ErrorInfo, ReactNode } from "react";
2 | import { AlertTriangleIcon, RefreshCwIcon } from "lucide-react";
3 | import { Button } from "@/components/ui/button";
4 |
5 | interface Props {
6 | children: ReactNode;
7 | fallback?: ReactNode;
8 | onError?: (error: Error, errorInfo: ErrorInfo) => void;
9 | }
10 |
11 | interface State {
12 | hasError: boolean;
13 | error: Error | null;
14 | errorInfo: ErrorInfo | null;
15 | }
16 |
17 | class ErrorBoundary extends Component {
18 | constructor(props: Props) {
19 | super(props);
20 | this.state = {
21 | hasError: false,
22 | error: null,
23 | errorInfo: null
24 | };
25 | }
26 |
27 | static getDerivedStateFromError(error: Error): State {
28 | return {
29 | hasError: true,
30 | error,
31 | errorInfo: null
32 | };
33 | }
34 |
35 | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
36 | this.setState({
37 | error,
38 | errorInfo
39 | });
40 |
41 | // Log error to console in development
42 | if (process.env.NODE_ENV === "development") {
43 | console.error("ErrorBoundary caught an error:", error, errorInfo);
44 | }
45 |
46 | // Call custom error handler if provided
47 | if (this.props.onError) {
48 | this.props.onError(error, errorInfo);
49 | }
50 |
51 | // In production, you might want to send this to an error reporting service
52 | // Example: Sentry.captureException(error, { contexts: { react: errorInfo } });
53 | }
54 |
55 | handleReset = () => {
56 | this.setState({
57 | hasError: false,
58 | error: null,
59 | errorInfo: null
60 | });
61 | };
62 |
63 | handleReload = () => {
64 | window.location.reload();
65 | };
66 |
67 | render() {
68 | if (this.state.hasError) {
69 | // Custom fallback UI
70 | if (this.props.fallback) {
71 | return this.props.fallback;
72 | }
73 |
74 | // Default error UI
75 | return (
76 |
77 |
78 |
81 |
82 |
83 |
84 | Something went wrong
85 |
86 |
87 | An unexpected error occurred. Please try refreshing the page or
88 | contact support if the problem persists.
89 |
90 |
91 |
92 | {process.env.NODE_ENV === "development" && this.state.error && (
93 |
94 |
95 | Error Details (Development Only)
96 |
97 |
98 |
99 | Error: {this.state.error.message}
100 |
101 |
102 |
Stack:
103 |
104 | {this.state.error.stack}
105 |
106 |
107 | {this.state.errorInfo && (
108 |
109 |
Component Stack:
110 |
111 | {this.state.errorInfo.componentStack}
112 |
113 |
114 | )}
115 |
116 |
117 | )}
118 |
119 |
120 |
121 |
122 | Try Again
123 |
124 | Reload Page
125 |
126 |
127 |
128 | );
129 | }
130 |
131 | return this.props.children;
132 | }
133 | }
134 |
135 | export default ErrorBoundary;
136 |
137 | // Hook version for functional components that need error boundary functionality
138 | export const useErrorHandler = () => {
139 | return (error: Error, errorInfo?: ErrorInfo) => {
140 | console.error("Unhandled error:", error, errorInfo);
141 |
142 | // In production, send to error reporting service
143 | // Example: Sentry.captureException(error);
144 | };
145 | };
146 |
147 | // Higher-order component for wrapping components with error boundary
148 | export const withErrorBoundary = (
149 | Component: React.ComponentType
,
150 | fallback?: ReactNode,
151 | onError?: (error: Error, errorInfo: ErrorInfo) => void
152 | ) => {
153 | const WrappedComponent = (props: P) => (
154 |
155 |
156 |
157 | );
158 |
159 | WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
160 |
161 | return WrappedComponent;
162 | };
163 |
--------------------------------------------------------------------------------
/src/sqlite/demo-db.ts:
--------------------------------------------------------------------------------
1 | // The demo database for the main loaded database
2 | const DEMO_DB = `
3 | -- Enable foreign key support
4 | PRAGMA foreign_keys = ON;
5 |
6 | -- Create Customers Table
7 | CREATE TABLE Customers (
8 | id INTEGER PRIMARY KEY AUTOINCREMENT,
9 | first_name TEXT NOT NULL,
10 | last_name TEXT NOT NULL,
11 | email TEXT NOT NULL UNIQUE,
12 | phone TEXT
13 | );
14 |
15 | -- Create Products Table
16 | CREATE TABLE Products (
17 | id INTEGER PRIMARY KEY AUTOINCREMENT,
18 | name TEXT NOT NULL,
19 | description TEXT,
20 | price DECIMAL(10, 2) NOT NULL,
21 | created_at DATE NOT NULL
22 | );
23 |
24 | -- Create Orders Table with Foreign Key Constraints
25 | CREATE TABLE Orders (
26 | id INTEGER PRIMARY KEY AUTOINCREMENT,
27 | customer_id INTEGER NOT NULL,
28 | product_id INTEGER NOT NULL,
29 | order_date DATE NOT NULL,
30 | quantity INTEGER NOT NULL,
31 | FOREIGN KEY (customer_id) REFERENCES Customers(id) ON DELETE CASCADE,
32 | FOREIGN KEY (product_id) REFERENCES Products(id) ON DELETE CASCADE
33 | );
34 |
35 | -- Insert 30 Customers
36 | INSERT INTO Customers (first_name, last_name, email, phone) VALUES
37 | ('Emma', 'Rodriguez', 'emma.rodriguez@gmail.com', '(555) 123-4567'),
38 | ('Liam', 'Chen', 'liam.chen@example.com', '(555) 234-5678'),
39 | ('Olivia', 'Patel', 'olivia.patel@hotmail.com', '(555) 345-6789'),
40 | ('Noah', 'Kim', 'noah.kim@live.com', '(555) 456-7890'),
41 | ('Ava', 'Singh', 'ava.singh@gmail.com', '(555) 567-8901'),
42 | ('Ethan', 'Garcia', 'ethan.garcia@hotmail.com', '(555) 678-9012'),
43 | ('Sophia', 'Nguyen', 'sophia.nguyen@live.com', '(555) 789-0123'),
44 | ('Mason', 'Wang', 'mason.wang@example.com', '(555) 890-1234'),
45 | ('Isabella', 'Kumar', 'isabella.kumar@outlook.com', '(555) 901-2345'),
46 | ('William', 'Hernandez', 'william.hernandez@example.com', '(555) 012-3456'),
47 | ('Mia', 'Lee', 'mia.lee@example.com', '(555) 987-6543'),
48 | ('James', 'Gupta', 'james.gupta@outlook.com', '(555) 876-5432'),
49 | ('Charlotte', 'Martinez', 'charlotte.martinez@example.com', '(555) 765-4321'),
50 | ('Benjamin', 'Suzuki', 'benjamin.suzuki@hotmail.com', '(555) 654-3210'),
51 | ('Amelia', 'Ahmed', 'amelia.ahmed@outlook.com', '(555) 543-2109'),
52 | ('Lucas', 'Park', 'lucas.park@hotmail.com', '(555) 432-1098'),
53 | ('Harper', 'Tanaka', 'harper.tanaka@gmail.com', '(555) 321-0987'),
54 | ('Alexander', 'Mehta', 'alexander.mehta@proton.com', '(555) 210-9876'),
55 | ('Evelyn', 'Sato', 'evelyn.sato@outlook.com', '(555) 109-8765'),
56 | ('Michael', 'Khan', 'michael.khan@outlook.com', '(555) 098-7654'),
57 | ('Abigail', 'Yamaguchi', 'abigail.yamaguchi@proton.com', '(555) 876-5432'),
58 | ('Daniel', 'Choi', 'daniel.choi@gmail.com', '(555) 765-4321'),
59 | ('Emily', 'Gupta', 'emily.gupta@example.com', '(555) 654-3210'),
60 | ('Jacob', 'Liu', 'jacob.liu@proton.com', '(555) 543-2109'),
61 | ('Madison', 'Cho', 'madison.cho@hotmail.com', '(555) 432-1098'),
62 | ('Logan', 'Pham', 'logan.pham@gmail.com', '(555) 321-0987'),
63 | ('Elizabeth', 'Nakamura', 'elizabeth.nakamura@example.com', '(555) 210-9876'),
64 | ('Sebastian', 'Malik', 'sebastian.malik@example.com', '(555) 109-8765'),
65 | ('Avery', 'Suzuki', 'avery.suzuki@hotmail.com', '(555) 098-7654'),
66 | ('Jackson', 'Krishnamurthy', 'jackson.krishnamurthy@gmail.com', '(555) 987-6543');
67 |
68 | -- Insert 20 Products
69 | INSERT INTO Products (name, description, price, created_at) VALUES
70 | ('Smart Home Hub', 'Central control system for smart home devices', 129.99, '2025-01-15'),
71 | ('Wireless Earbuds', 'Noise-cancelling earbuds with long battery life', 199.99, '2025-01-16'),
72 | ('Fitness Tracker', 'Advanced health monitoring smartwatch', 149.99, '2025-01-17'),
73 | ('4K Action Camera', 'Waterproof camera for extreme sports', 299.99, '2025-01-18'),
74 | ('Portable Solar Charger', 'Eco-friendly charging solution for devices', 79.99, '2025-01-19'),
75 | ('Smart Robot Vacuum', 'AI-powered cleaning robot with mapping', 249.99, '2025-01-20'),
76 | ('Noise-Cancelling Headphones', 'Premium over-ear wireless headphones', 349.99, '2025-01-21'),
77 | ('Portable Power Station', 'High-capacity battery for outdoor adventures', 399.99, '2025-01-22'),
78 | ('Smart Garden Sensor', 'Monitors plant health and soil conditions', 59.99, '2025-01-23'),
79 | ('Electric Skateboard', 'Compact personal transportation device', 499.99, '2025-01-24'),
80 | ('Compact Drone', 'Portable aerial photography drone', 299.99, '2025-01-25'),
81 | ('Smart Thermostat', 'Energy-efficient home temperature control', 179.99, '2025-01-26'),
82 | ('Portable Projector', 'Pocket-sized HD movie projector', 249.99, '2025-01-27'),
83 | ('Wireless Charging Pad', 'Multi-device fast charging station', 69.99, '2025-01-28'),
84 | ('Smart Water Bottle', 'Hydration tracking and temperature control', 39.99, '2025-01-29'),
85 | ('Bluetooth Speaker', 'Waterproof outdoor sound system', 129.99, '2025-01-30'),
86 | ('Digital Writing Tablet', 'Electronic notebook with cloud sync', 199.99, '2025-02-01'),
87 | ('Portable Air Purifier', 'Compact air cleaning device', 99.99, '2025-02-02'),
88 | ('Smart Light Bulbs', 'Color-changing WiFi-enabled lighting', 29.99, '2025-02-03'),
89 | ('Wireless Game Controller', 'Universal gaming controller', 79.99, '2025-02-04');
90 |
91 | -- Insert 10 Orders
92 | INSERT INTO Orders (customer_id, product_id, order_date, quantity) VALUES
93 | (1, 5, '2025-02-10', 2),
94 | (7, 12, '2025-02-11', 1),
95 | (15, 3, '2025-02-12', 3),
96 | (22, 8, '2025-02-13', 1),
97 | (9, 16, '2025-02-14', 2),
98 | (18, 1, '2025-02-15', 1),
99 | (25, 7, '2025-02-16', 1),
100 | (11, 19, '2025-02-17', 4),
101 | (29, 11, '2025-02-18', 1),
102 | (5, 14, '2025-02-19', 2);
103 |
104 | -- Verification Queries
105 | SELECT 'Customers Count:', COUNT(*) FROM Customers;
106 | SELECT 'Products Count:', COUNT(*) FROM Products;
107 | SELECT 'Orders Count:', COUNT(*) FROM Orders;
108 | `;
109 |
110 | export default DEMO_DB;
111 |
--------------------------------------------------------------------------------
/src/components/browseTab/PaginationControls.tsx:
--------------------------------------------------------------------------------
1 | import useDatabaseWorker from "@/hooks/useWorker";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 | import usePanelManager from "@/hooks/usePanel";
4 |
5 | import { Button } from "@/components/ui/button";
6 |
7 | import {
8 | ChevronFirstIcon,
9 | ChevronLastIcon,
10 | ChevronLeftIcon,
11 | ChevronRightIcon,
12 | FolderOutputIcon,
13 | PlusIcon
14 | } from "lucide-react";
15 |
16 | function PaginationControls() {
17 | const { handlePageChange, handleExport } = useDatabaseWorker();
18 | const { isInserting, handleInsert } = usePanelManager();
19 | const offset = useDatabaseStore((state) => state.offset);
20 | const limit = useDatabaseStore((state) => state.limit);
21 | const maxSize = useDatabaseStore((state) => state.maxSize);
22 | const isDataLoading = useDatabaseStore((state) => state.isDataLoading);
23 | const isView = useDatabaseStore(
24 | (state) => state.tablesSchema[state.currentTable!]?.type === "view"
25 | );
26 |
27 | const currentPage = Math.floor(offset / limit) + 1;
28 | const totalPages = Math.ceil(maxSize / limit);
29 |
30 | return (
31 |
143 | );
144 | }
145 |
146 | export default PaginationControls;
147 |
--------------------------------------------------------------------------------
/src/components/executeTab/ExecuteTab.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 |
3 | import { useDatabaseStore } from "@/store/useDatabaseStore";
4 | import { usePanelStore } from "@/store/usePanelStore";
5 | import useDatabaseWorker from "@/hooks/useWorker";
6 | import { useGeminiAI } from "@/hooks/useGeminiAI";
7 |
8 | import {
9 | ResizableHandle,
10 | ResizablePanel,
11 | ResizablePanelGroup
12 | } from "@/components/ui/resizable";
13 | import { Button } from "@/components/ui/button";
14 | import SchemaTree from "@/components/structureTab/SchemaTree";
15 | import CustomSQLTextarea from "./CustomSQLTextarea";
16 | import CustomQueryDataTable from "./CustomQueryDataTable";
17 | import ApiKeyModal from "./ApiKeyModal";
18 |
19 | import {
20 | PlayIcon,
21 | LoaderCircleIcon,
22 | XIcon,
23 | FolderOutputIcon,
24 | SparklesIcon
25 | } from "lucide-react";
26 |
27 | function ExecuteTab() {
28 | const errorMessage = useDatabaseStore((state) => state.errorMessage);
29 | const setErrorMessage = useDatabaseStore((state) => state.setErrorMessage);
30 | const isDataLoading = useDatabaseStore((state) => state.isDataLoading);
31 | const isDatabaseLoading = useDatabaseStore(
32 | (state) => state.isDatabaseLoading
33 | );
34 |
35 | const dataPanelSize = usePanelStore((state) => state.dataPanelSize);
36 | const schemaPanelSize = usePanelStore((state) => state.schemaPanelSize);
37 | const setDataPanelSize = usePanelStore((state) => state.setDataPanelSize);
38 | const setSchemaPanelSize = usePanelStore((state) => state.setSchemaPanelSize);
39 | const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
40 |
41 | const customQueryObject = useDatabaseStore(
42 | (state) => state.customQueryObject
43 | );
44 | const customQuery = useDatabaseStore((state) => state.customQuery);
45 |
46 | const { handleQueryExecute, handleExport } = useDatabaseWorker();
47 | const { generateSqlQuery, isAiLoading } = useGeminiAI();
48 |
49 | const handleExecuteClick = () => {
50 | if (customQuery && customQuery.startsWith("/ai ")) {
51 | generateSqlQuery();
52 | } else {
53 | handleQueryExecute();
54 | }
55 | };
56 |
57 | const handleErrorClose = useCallback(() => {
58 | setErrorMessage(null);
59 | }, [setErrorMessage]);
60 |
61 | const handleApiKeyModalOpen = () => {
62 | setIsApiKeyModalOpen(true);
63 | };
64 |
65 | return (
66 |
67 |
68 |
76 | {isAiLoading ? (
77 |
78 | ) : (
79 |
80 | )}
81 | Execute SQL
82 |
83 |
84 |
handleExport("custom")}
86 | size="sm"
87 | variant="outline"
88 | className="text-xs"
89 | disabled={!customQueryObject?.data}
90 | >
91 |
92 | Export data
93 |
94 |
100 |
101 | Gemini
102 |
103 | {(isDataLoading || isDatabaseLoading) && (
104 |
105 |
106 | Loading data
107 |
108 | )}
109 |
110 |
111 |
112 |
113 |
117 |
118 |
119 | {errorMessage && (
120 |
121 |
{errorMessage}
122 |
128 |
129 |
130 |
131 | )}
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
151 |
156 |
157 |
158 |
setIsApiKeyModalOpen(false)}
161 | />
162 |
163 |
164 | );
165 | }
166 |
167 | export default ExecuteTab;
168 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useDatabaseStore } from "./store/useDatabaseStore";
2 |
3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4 | import TopBar from "@/components/TopBar";
5 | import StructureTab from "@/components/structureTab/StructureTab";
6 | import BrowseTab from "@/components/browseTab/BrowseTab";
7 | import ExecuteTab from "@/components/executeTab/ExecuteTab";
8 |
9 | import FileDropHandler from "@/components/FileDropHandler";
10 | import DatabaseURLLoader from "./components/DatabaseURLLoader";
11 | import SkipLinks from "@/components/accessibility/SkipLinks";
12 | import LiveRegion from "@/components/accessibility/LiveRegion";
13 |
14 | import {
15 | CodeIcon,
16 | DatabaseIcon,
17 | LoaderCircleIcon,
18 | TableIcon
19 | } from "lucide-react";
20 |
21 | function App() {
22 | const isDatabaseLoading = useDatabaseStore(
23 | (state) => state.isDatabaseLoading
24 | );
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
36 |
37 |
38 |
43 |
54 |
58 | Browse Data
59 |
60 |
71 |
75 | Execute SQL
76 |
77 |
88 |
92 | Database Structure
93 |
94 |
95 |
96 |
97 |
103 | {isDatabaseLoading ? (
104 |
109 |
110 |
114 |
115 |
119 |
120 | Loading Database
121 |
122 |
123 | Please wait while the database is initializing
124 |
125 |
126 |
127 | ) : (
128 |
129 | )}
130 |
131 |
137 |
138 |
139 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | >
153 | );
154 | }
155 |
156 | export default App;
157 |
--------------------------------------------------------------------------------
/src/components/DatabaseURLLoader.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useCallback, useRef } from "react";
2 | import useDatabaseWorker from "@/hooks/useWorker";
3 | import { useDatabaseStore } from "@/store/useDatabaseStore";
4 |
5 | import showToast from "@/components/common/Toaster/Toast";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogFooter,
11 | DialogHeader,
12 | DialogTitle
13 | } from "@/components/ui/dialog";
14 | import { Button } from "./ui/button";
15 |
16 | function DatabaseURLLoader() {
17 | const { handleFileUpload } = useDatabaseWorker();
18 | const setDatabaseLoading = useDatabaseStore(
19 | (state) => state.setIsDatabaseLoading
20 | );
21 |
22 | const [fetchError, setFetchError] = useState(null);
23 | const [urlToFetch, setUrlToFetch] = useState(null);
24 | const [showProxyDialog, setShowProxyDialog] = useState(false);
25 |
26 | const initialCheckDone = useRef(false);
27 | const fetchInProgress = useRef(false);
28 |
29 | const isValidURL = useCallback((url: string) => {
30 | try {
31 | new URL(url);
32 | return true;
33 | } catch {
34 | return false;
35 | }
36 | }, []);
37 |
38 | const fetchDatabase = useCallback(
39 | async (url: string, useProxy = false) => {
40 | if (fetchInProgress.current) {
41 | console.log("Fetch already in progress, ignoring duplicate call");
42 | return false;
43 | }
44 |
45 | fetchInProgress.current = true;
46 |
47 | if (!isValidURL(url)) {
48 | const message = "Invalid URL format";
49 | setFetchError(message);
50 | showToast(message, "error");
51 | fetchInProgress.current = false;
52 | return false;
53 | }
54 |
55 | try {
56 | setDatabaseLoading(true);
57 |
58 | const fetchUrl = useProxy
59 | ? `https://corsproxy.io/?url=${encodeURIComponent(url)}`
60 | : url;
61 |
62 | const response = await fetch(fetchUrl, {
63 | method: "GET"
64 | });
65 |
66 | if (!response.ok) {
67 | throw new Error(`HTTP error! status: ${response.status}`);
68 | }
69 |
70 | if (!useProxy) {
71 | showToast("Retrieving database, please wait", "info");
72 | } else {
73 | showToast("Retrieving database with CORS proxy, please wait", "info");
74 | }
75 |
76 | const blob = await response.blob();
77 |
78 | if (blob.size < 100) {
79 | throw new Error(
80 | "The downloaded file seems too small to be a valid SQLite database"
81 | );
82 | }
83 |
84 | const file = new File([blob], "database.sqlite");
85 |
86 | handleFileUpload(file);
87 |
88 | showToast("Database loaded successfully", "success");
89 |
90 | setFetchError(null);
91 | return true;
92 | } catch (error) {
93 | const errorMsg = error instanceof Error ? error.message : String(error);
94 | setFetchError(errorMsg);
95 |
96 | if (useProxy) {
97 | showToast(`Failed to load with CORS proxy: ${errorMsg}`, "error");
98 | } else {
99 | // First attempt failed, show dialog
100 | setUrlToFetch(url);
101 | setShowProxyDialog(true);
102 | }
103 |
104 | return false;
105 | } finally {
106 | setDatabaseLoading(false);
107 | fetchInProgress.current = false;
108 | }
109 | },
110 | [handleFileUpload, setDatabaseLoading]
111 | );
112 |
113 | useEffect(() => {
114 | if (initialCheckDone.current) {
115 | return;
116 | }
117 |
118 | initialCheckDone.current = true;
119 | const abortController = new AbortController();
120 |
121 | const checkURLParam = async () => {
122 | const urlParams = new URLSearchParams(window.location.search);
123 | const url = urlParams.get("url");
124 |
125 | if (url) {
126 | const decodedUrl = decodeURIComponent(url);
127 | try {
128 | await fetchDatabase(decodedUrl);
129 | } catch (error) {
130 | console.error("Initial fetch error:", error);
131 | }
132 | }
133 | };
134 |
135 | checkURLParam();
136 |
137 | return () => {
138 | abortController.abort();
139 | };
140 | }, [fetchDatabase]);
141 |
142 | const handleRetryWithProxy = useCallback(() => {
143 | if (urlToFetch) {
144 | fetchDatabase(urlToFetch, true);
145 | setShowProxyDialog(false);
146 | }
147 | }, [urlToFetch, fetchDatabase]);
148 |
149 | return (
150 |
151 |
152 |
153 |
154 | CORS Error Detected
155 |
156 |
157 | Something went wrong while loading the database. This might be due
158 | to Cross-Origin Resource Sharing (CORS) restrictions.
159 |
160 |
161 |
162 |
163 |
164 | Would you like to try using a CORS proxy?{" "}
165 |
166 | Note that this will send your database request through a
167 | third-party service.
168 |
169 |
170 |
171 | {fetchError && (
172 |
173 |
Error details: {fetchError}
174 |
175 | )}
176 |
177 |
178 |
179 | {
182 | setShowProxyDialog(false);
183 | setFetchError(null);
184 | }}
185 | >
186 | Cancel
187 |
188 |
189 | Use CORS Proxy
190 |
191 |
192 |
193 |
194 | );
195 | }
196 |
197 | export default DatabaseURLLoader;
198 |
--------------------------------------------------------------------------------
/src/lib/secureStorage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Secure storage utility for sensitive data like API keys
3 | * Uses encryption when possible and provides fallback to localStorage
4 | */
5 |
6 | // Simple encryption/decryption using Web Crypto API
7 | class SecureStorage {
8 | private static readonly STORAGE_KEY_PREFIX = "secure_";
9 | private static readonly ENCRYPTION_KEY_NAME = "app_encryption_key";
10 |
11 | /**
12 | * Generate or retrieve encryption key
13 | */
14 | private static async getEncryptionKey(): Promise {
15 | // Try to get existing key from IndexedDB or generate new one
16 | try {
17 | const keyData = localStorage.getItem(this.ENCRYPTION_KEY_NAME);
18 | if (keyData) {
19 | const keyBuffer = new Uint8Array(JSON.parse(keyData));
20 | return await crypto.subtle.importKey(
21 | "raw",
22 | keyBuffer,
23 | { name: "AES-GCM" },
24 | false,
25 | ["encrypt", "decrypt"]
26 | );
27 | }
28 | } catch (error) {
29 | console.warn("Failed to retrieve existing encryption key:", error);
30 | }
31 |
32 | // Generate new key
33 | const key = await crypto.subtle.generateKey(
34 | { name: "AES-GCM", length: 256 },
35 | true,
36 | ["encrypt", "decrypt"]
37 | );
38 |
39 | // Store key for future use (in production, this should be more secure)
40 | try {
41 | const keyBuffer = await crypto.subtle.exportKey("raw", key);
42 | localStorage.setItem(
43 | this.ENCRYPTION_KEY_NAME,
44 | JSON.stringify(Array.from(new Uint8Array(keyBuffer)))
45 | );
46 | } catch (error) {
47 | console.warn("Failed to store encryption key:", error);
48 | }
49 |
50 | return key;
51 | }
52 |
53 | /**
54 | * Encrypt data using AES-GCM
55 | */
56 | private static async encrypt(data: string): Promise {
57 | try {
58 | const key = await this.getEncryptionKey();
59 | const encoder = new TextEncoder();
60 | const dataBuffer = encoder.encode(data);
61 |
62 | const iv = crypto.getRandomValues(new Uint8Array(12));
63 | const encryptedBuffer = await crypto.subtle.encrypt(
64 | { name: "AES-GCM", iv },
65 | key,
66 | dataBuffer
67 | );
68 |
69 | // Combine IV and encrypted data
70 | const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
71 | combined.set(iv);
72 | combined.set(new Uint8Array(encryptedBuffer), iv.length);
73 |
74 | return btoa(String.fromCharCode(...combined));
75 | } catch (error) {
76 | console.error("Encryption failed:", error);
77 | throw new Error("Failed to encrypt data");
78 | }
79 | }
80 |
81 | /**
82 | * Decrypt data using AES-GCM
83 | */
84 | private static async decrypt(encryptedData: string): Promise {
85 | try {
86 | const key = await this.getEncryptionKey();
87 | const combined = new Uint8Array(
88 | atob(encryptedData)
89 | .split("")
90 | .map((char) => char.charCodeAt(0))
91 | );
92 |
93 | const iv = combined.slice(0, 12);
94 | const encrypted = combined.slice(12);
95 |
96 | const decryptedBuffer = await crypto.subtle.decrypt(
97 | { name: "AES-GCM", iv },
98 | key,
99 | encrypted
100 | );
101 |
102 | const decoder = new TextDecoder();
103 | return decoder.decode(decryptedBuffer);
104 | } catch (error) {
105 | console.error("Decryption failed:", error);
106 | throw new Error("Failed to decrypt data");
107 | }
108 | }
109 |
110 | /**
111 | * Store sensitive data securely
112 | */
113 | static async setItem(key: string, value: string): Promise {
114 | const storageKey = this.STORAGE_KEY_PREFIX + key;
115 |
116 | if (typeof window === "undefined") {
117 | throw new Error(
118 | "Secure storage is only available in browser environment"
119 | );
120 | }
121 |
122 | try {
123 | // Try to use encryption if Web Crypto API is available
124 | if (window.crypto && window.crypto.subtle) {
125 | const encryptedValue = await this.encrypt(value);
126 | localStorage.setItem(
127 | storageKey,
128 | JSON.stringify({
129 | encrypted: true,
130 | data: encryptedValue
131 | })
132 | );
133 | } else {
134 | // Fallback to base64 encoding (not secure, but better than plain text)
135 | console.warn("Web Crypto API not available, using base64 encoding");
136 | const encodedValue = btoa(value);
137 | localStorage.setItem(
138 | storageKey,
139 | JSON.stringify({
140 | encrypted: false,
141 | data: encodedValue
142 | })
143 | );
144 | }
145 | } catch (error) {
146 | console.error("Failed to store secure data:", error);
147 | throw new Error("Failed to store sensitive data");
148 | }
149 | }
150 |
151 | /**
152 | * Retrieve sensitive data securely
153 | */
154 | static async getItem(key: string): Promise {
155 | const storageKey = this.STORAGE_KEY_PREFIX + key;
156 |
157 | if (typeof window === "undefined") {
158 | return null;
159 | }
160 |
161 | try {
162 | const storedData = localStorage.getItem(storageKey);
163 | if (!storedData) {
164 | return null;
165 | }
166 |
167 | const parsed = JSON.parse(storedData);
168 |
169 | if (parsed.encrypted) {
170 | return await this.decrypt(parsed.data);
171 | } else {
172 | // Fallback for base64 encoded data
173 | return atob(parsed.data);
174 | }
175 | } catch (error) {
176 | console.error("Failed to retrieve secure data:", error);
177 | return null;
178 | }
179 | }
180 |
181 | /**
182 | * Remove sensitive data
183 | */
184 | static removeItem(key: string): void {
185 | const storageKey = this.STORAGE_KEY_PREFIX + key;
186 |
187 | if (typeof window !== "undefined") {
188 | localStorage.removeItem(storageKey);
189 | }
190 | }
191 |
192 | /**
193 | * Check if Web Crypto API is available
194 | */
195 | static isSecureStorageAvailable(): boolean {
196 | return (
197 | typeof window !== "undefined" &&
198 | window.crypto &&
199 | window.crypto.subtle !== undefined
200 | );
201 | }
202 | }
203 |
204 | export default SecureStorage;
205 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 | import * as SelectPrimitive from "@radix-ui/react-select";
3 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function Select({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | // function SelectGroup({
14 | // ...props
15 | // }: React.ComponentProps) {
16 | // return ;
17 | // }
18 |
19 | function SelectValue({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function SelectTrigger({
26 | className,
27 | children,
28 | ...props
29 | }: React.ComponentProps) {
30 | return (
31 |
39 | {children}
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | function SelectContent({
48 | className,
49 | children,
50 | position = "popper",
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
66 |
67 |
74 | {children}
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | // function SelectLabel({
83 | // className,
84 | // ...props
85 | // }: React.ComponentProps) {
86 | // return (
87 | //
92 | // );
93 | // }
94 |
95 | function SelectItem({
96 | className,
97 | children,
98 | ...props
99 | }: React.ComponentProps) {
100 | return (
101 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | );
117 | }
118 |
119 | // function SelectSeparator({
120 | // className,
121 | // ...props
122 | // }: React.ComponentProps) {
123 | // return (
124 | //
129 | // );
130 | // }
131 |
132 | const SelectScrollUpButton = function SelectScrollUpButton({
133 | className,
134 | ...props
135 | }: React.ComponentProps) {
136 | return (
137 |
145 |
146 |
147 | );
148 | };
149 |
150 | function SelectScrollDownButton({
151 | className,
152 | ...props
153 | }: React.ComponentProps) {
154 | return (
155 |
163 |
164 |
165 | );
166 | }
167 |
168 | export {
169 | Select,
170 | SelectContent,
171 | // SelectGroup,
172 | SelectItem,
173 | // SelectLabel,
174 | // SelectScrollDownButton,
175 | // SelectScrollUpButton,
176 | // SelectSeparator,
177 | SelectTrigger,
178 | SelectValue
179 | };
180 |
--------------------------------------------------------------------------------
/src/components/browseTab/EditSection.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo } from "react";
2 | import useDatabaseWorker from "@/hooks/useWorker";
3 | import usePanelManager from "@/hooks/usePanel";
4 | import { useDatabaseStore } from "@/store/useDatabaseStore";
5 | import { usePanelStore } from "@/store/usePanelStore";
6 |
7 | import { isDate, isNumber, isText } from "@/sqlite/sqlite-type-check";
8 |
9 | import { Button } from "@/components/ui/button";
10 | import { Input } from "@/components/ui/input";
11 | import { Span } from "@/components/ui/span";
12 | import ColumnIcon from "@/components/table/ColumnIcon";
13 | import { Textarea } from "../ui/textarea";
14 |
15 | import {
16 | ChevronLeftIcon,
17 | PlusIcon,
18 | SquarePenIcon,
19 | Trash2Icon
20 | } from "lucide-react";
21 |
22 | function EditSection() {
23 | const { handleEditSubmit } = useDatabaseWorker();
24 | const { selectedRowObject, isInserting, handleCloseEdit } = usePanelManager();
25 | const editValues = usePanelStore((state) => state.editValues);
26 | const setEditValues = usePanelStore((state) => state.setEditValues);
27 | const tablesSchema = useDatabaseStore((state) => state.tablesSchema);
28 | const currentTable = useDatabaseStore((state) => state.currentTable);
29 | const columns = useDatabaseStore((state) => state.columns);
30 |
31 | useEffect(() => {
32 | if (isInserting) {
33 | setEditValues(columns?.map(() => "") || []);
34 | } else if (selectedRowObject) {
35 | setEditValues(
36 | selectedRowObject.data.map((value) => value?.toString() ?? "")
37 | );
38 | }
39 | }, [isInserting, selectedRowObject, columns, setEditValues]);
40 |
41 | const handleEditInputChange = useCallback(
42 | (index: number, newValue: string) => {
43 | const currentEditValues = usePanelStore.getState().editValues;
44 | setEditValues(
45 | currentEditValues.map((value, i) => (i === index ? newValue : value))
46 | );
47 | },
48 | [setEditValues]
49 | );
50 |
51 | const formItems = useMemo(() => {
52 | if (!columns || !currentTable || !tablesSchema[currentTable]) return null;
53 |
54 | const schema = tablesSchema[currentTable]?.schema;
55 |
56 | return columns.map((column, index) => {
57 | const columnSchema = schema[index];
58 | const placeholder = columnSchema?.dflt_value || "Null";
59 | const inputType = (() => {
60 | const typeValue = columnSchema?.type ?? "";
61 | if (isNumber(typeValue)) return "number";
62 | if (isDate(typeValue)) return "date";
63 | return "text";
64 | })();
65 |
66 | return (
67 |
68 |
72 |
73 |
74 |
75 | {column}
76 |
77 | {columnSchema.IsNullable && (
78 |
79 | Nullable
80 |
81 | )}
82 |
83 |
84 |
85 | {isText(columnSchema?.type ?? "") ? (
86 |
106 |
107 | );
108 | });
109 | }, [columns, currentTable, tablesSchema, editValues, handleEditInputChange]);
110 |
111 | const actionButtons = useMemo(
112 | () => (
113 |
114 |
handleEditSubmit(isInserting ? "insert" : "update")}
119 | aria-label={isInserting ? "Insert row" : "Apply changes"}
120 | disabled={tablesSchema[currentTable!]?.type === "view"}
121 | >
122 | {isInserting ? (
123 | <>
124 |
125 | Insert row
126 | >
127 | ) : (
128 | <>
129 |
130 | Apply changes
131 | >
132 | )}
133 |
134 | {!isInserting && (
135 |
handleEditSubmit("delete")}
140 | aria-label="Delete row"
141 | disabled={tablesSchema[currentTable!]?.type === "view"}
142 | >
143 |
144 |
145 | )}
146 |
147 | ),
148 | [handleEditSubmit, isInserting, currentTable, tablesSchema]
149 | );
150 |
151 | const sectionHeader = useMemo(
152 | () => (
153 |
154 |
155 | {isInserting ? (
156 | <>
157 |
160 |
161 | Inserting new row
162 |
163 | >
164 | ) : (
165 | <>
166 |
167 |
168 |
169 |
170 | Updating row
171 |
172 | >
173 | )}
174 |
175 |
183 |
184 | Back
185 |
186 |
187 | ),
188 | [isInserting, handleCloseEdit]
189 | );
190 |
191 | return (
192 |
193 | {sectionHeader}
194 | {formItems}
195 | {actionButtons}
196 |
197 | );
198 | }
199 |
200 | export default EditSection;
201 |
--------------------------------------------------------------------------------
/src/components/browseTab/DataTable.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { useDatabaseStore } from "@/store/useDatabaseStore";
3 | import useDatabaseWorker from "@/hooks/useWorker";
4 | import usePanelManager from "@/hooks/usePanel";
5 |
6 | import {
7 | Table,
8 | TableBody,
9 | TableCell,
10 | TableHead,
11 | TableHeader,
12 | TableRow
13 | } from "@/components/ui/table";
14 | import { Button } from "@/components/ui/button";
15 | import { Span } from "@/components/ui/span";
16 | import ColumnIcon from "@/components/table/ColumnIcon";
17 | import FilterInput from "@/components/table/FilterInput";
18 | import Badge from "@/components/ui/badge";
19 | import SorterButton from "../table/SorterButton";
20 |
21 | import { DatabaseIcon, FilterXIcon } from "lucide-react";
22 |
23 | function DataTable() {
24 | const data = useDatabaseStore((state) => state.data);
25 | const columns = useDatabaseStore((state) => state.columns);
26 | const currentTable = useDatabaseStore((state) => state.currentTable);
27 | const tablesSchema = useDatabaseStore((state) => state.tablesSchema);
28 | const filters = useDatabaseStore((state) => state.filters);
29 | const sorters = useDatabaseStore((state) => state.sorters);
30 | const setFilters = useDatabaseStore((state) => state.setFilters);
31 |
32 | const { handleQueryFilter } = useDatabaseWorker();
33 | const { handleRowClick, selectedRowObject } = usePanelManager();
34 |
35 | const emptyDataContent = useMemo(
36 | () => (
37 |
38 | {filters ? (
39 | <>
40 |
41 |
No Data To Show
42 |
43 | The current filters did not return any results
44 |
45 |
46 |
setFilters(null)}
51 | >
52 |
53 | Clear filters
54 |
55 | >
56 | ) : (
57 |
58 |
59 |
60 |
61 |
62 |
No Data To Show
63 |
64 | This table does not have any data to display
65 |
66 |
67 |
68 | )}
69 |
70 | ),
71 | [filters, setFilters]
72 | );
73 |
74 | const memoizedFilterInput = useMemo(() => {
75 | return (columns || []).map((column) => (
76 |
82 | ));
83 | }, [columns, filters, handleQueryFilter]);
84 |
85 | return (
86 |
87 |
93 |
94 |
95 | {columns && currentTable ? (
96 | columns.map((column, index) => (
97 |
109 |
110 |
111 |
112 | {column}
113 |
114 |
117 |
118 | {memoizedFilterInput?.[index]}
119 |
120 | ))
121 | ) : (
122 |
123 | No columns found
124 |
125 | )}
126 |
127 |
128 |
129 | {data && data.length > 0 ? (
130 | data.map((row, i) => {
131 | const isView = tablesSchema[currentTable!]?.type === "view";
132 | const primaryKey = tablesSchema[currentTable!]?.primaryKey;
133 | const primaryValue = primaryKey && !isView ? row[0] : null;
134 | const displayData = primaryKey && !isView ? row.slice(1) : row;
135 |
136 | return (
137 | handleRowClick(displayData, i, primaryValue)}
140 | className="hover:bg-primary/5 focus:bg-primary/5 data-[state=selected]:bg-primary/5 focus:ring-ring cursor-pointer text-xs focus:ring-2 focus:ring-offset-1 focus:outline-none"
141 | role="row"
142 | data-state={
143 | selectedRowObject?.index === i ? "selected" : undefined
144 | }
145 | tabIndex={0}
146 | aria-label={`Row ${i + 1} of ${data.length}, click to edit`}
147 | onKeyDown={(e) => {
148 | if (e.key === "Enter" || e.key === " ") {
149 | e.preventDefault();
150 | handleRowClick(displayData, i, primaryValue);
151 | }
152 | }}
153 | >
154 | {displayData.map((value, j) => (
155 |
168 | {value === null ? (
169 |
170 | {value === null ? "NULL" : JSON.stringify(value)}
171 |
172 | ) : (
173 | <>
174 | {tablesSchema[currentTable!].schema[j]?.type ===
175 | "BLOB" ? (
176 | BLOB
177 | ) : (
178 | {value}
179 | )}
180 | >
181 | )}
182 |
183 | ))}
184 |
185 | );
186 | })
187 | ) : (
188 |
189 |
195 | {emptyDataContent}
196 |
197 |
198 | )}
199 |
200 |
201 |
202 | );
203 | }
204 |
205 | export default DataTable;
206 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @custom-variant dark (&:is(.dark *));
4 |
5 | :root {
6 | --background: oklch(96.42% 0 0);
7 | --foreground: oklch(0.145 0 0);
8 | --card: oklch(96.42% 0 0);
9 | --card-foreground: oklch(0.145 0 0);
10 | --popover: oklch(96.42% 0 0);
11 | --popover-foreground: oklch(0.145 0 0);
12 | --primary: oklch(0.205 0 0);
13 | --primary-foreground: oklch(0.985 0 0);
14 | --secondary: oklch(0.97 0 0);
15 | --secondary-foreground: oklch(0.205 0 0);
16 | --muted: oklch(0.97 0 0);
17 | --muted-foreground: oklch(0.556 0 0);
18 | --accent: oklch(0.97 0 0);
19 | --accent-foreground: oklch(0.205 0 0);
20 | --destructive: oklch(0.577 0.245 27.325);
21 | --destructive-foreground: oklch(0.577 0.245 27.325);
22 | --border: oklch(0.852 0 0);
23 | --input: oklch(0.922 0 0);
24 | --ring: oklch(0.708 0 0);
25 | --chart-1: oklch(0.646 0.222 41.116);
26 | --chart-2: oklch(0.6 0.118 184.704);
27 | --chart-3: oklch(0.398 0.07 227.392);
28 | --chart-4: oklch(0.828 0.189 84.429);
29 | --chart-5: oklch(0.769 0.188 70.08);
30 | --radius: 0.3rem;
31 | --sidebar: oklch(0.985 0 0);
32 | --sidebar-foreground: oklch(0.145 0 0);
33 | --sidebar-primary: oklch(0.205 0 0);
34 | --sidebar-primary-foreground: oklch(0.985 0 0);
35 | --sidebar-accent: oklch(0.97 0 0);
36 | --sidebar-accent-foreground: oklch(0.205 0 0);
37 | --sidebar-border: oklch(0.922 0 0);
38 | --sidebar-ring: oklch(0.708 0 0);
39 | }
40 |
41 | /* High Contrast Mode */
42 | .high-contrast {
43 | --background: oklch(100% 0 0);
44 | --foreground: oklch(0% 0 0);
45 | --card: oklch(100% 0 0);
46 | --card-foreground: oklch(0% 0 0);
47 | --popover: oklch(100% 0 0);
48 | --popover-foreground: oklch(0% 0 0);
49 | --primary: oklch(0% 0 0);
50 | --primary-foreground: oklch(100% 0 0);
51 | --secondary: oklch(90% 0 0);
52 | --secondary-foreground: oklch(0% 0 0);
53 | --muted: oklch(85% 0 0);
54 | --muted-foreground: oklch(20% 0 0);
55 | --accent: oklch(85% 0 0);
56 | --accent-foreground: oklch(0% 0 0);
57 | --destructive: oklch(40% 0.8 25);
58 | --destructive-foreground: oklch(100% 0 0);
59 | --border: oklch(0% 0 0);
60 | --input: oklch(95% 0 0);
61 | --ring: oklch(0% 0 0);
62 | }
63 |
64 | .high-contrast.dark {
65 | --background: oklch(0% 0 0);
66 | --foreground: oklch(100% 0 0);
67 | --card: oklch(0% 0 0);
68 | --card-foreground: oklch(100% 0 0);
69 | --popover: oklch(0% 0 0);
70 | --popover-foreground: oklch(100% 0 0);
71 | --primary: oklch(100% 0 0);
72 | --primary-foreground: oklch(0% 0 0);
73 | --secondary: oklch(15% 0 0);
74 | --secondary-foreground: oklch(100% 0 0);
75 | --muted: oklch(20% 0 0);
76 | --muted-foreground: oklch(80% 0 0);
77 | --accent: oklch(20% 0 0);
78 | --accent-foreground: oklch(100% 0 0);
79 | --destructive: oklch(60% 0.8 25);
80 | --destructive-foreground: oklch(0% 0 0);
81 | --border: oklch(100% 0 0);
82 | --input: oklch(10% 0 0);
83 | --ring: oklch(100% 0 0);
84 | }
85 |
86 | /* Hide scrollbar for synchronized header */
87 | .scrollbar-hide {
88 | -ms-overflow-style: none;
89 | scrollbar-width: none;
90 | }
91 |
92 | .scrollbar-hide::-webkit-scrollbar {
93 | display: none;
94 | }
95 |
96 | .dark {
97 | --background: oklch(20.02% 0 0);
98 | --foreground: oklch(0.985 0 0);
99 | --card: oklch(20.02% 0 0);
100 | --card-foreground: oklch(0.985 0 0);
101 | --popover: oklch(20.02% 0 0);
102 | --popover-foreground: oklch(0.985 0 0);
103 | --primary: oklch(0.985 0 0);
104 | --primary-foreground: oklch(0.205 0 0);
105 | --secondary: oklch(0.269 0 0);
106 | --secondary-foreground: oklch(0.985 0 0);
107 | --muted: oklch(0.269 0 0);
108 | --muted-foreground: oklch(0.708 0 0);
109 | --accent: oklch(0.269 0 0);
110 | --accent-foreground: oklch(0.985 0 0);
111 | --destructive: oklch(0.396 0.141 25.723);
112 | --destructive-foreground: oklch(0.637 0.237 25.331);
113 | --border: oklch(0.369 0 0);
114 | --input: oklch(0.269 0 0);
115 | --ring: oklch(0.439 0 0);
116 | --chart-1: oklch(0.488 0.243 264.376);
117 | --chart-2: oklch(0.696 0.17 162.48);
118 | --chart-3: oklch(0.769 0.188 70.08);
119 | --chart-4: oklch(0.627 0.265 303.9);
120 | --chart-5: oklch(0.645 0.246 16.439);
121 | --sidebar: oklch(0.205 0 0);
122 | --sidebar-foreground: oklch(0.985 0 0);
123 | --sidebar-primary: oklch(0.488 0.243 264.376);
124 | --sidebar-primary-foreground: oklch(0.985 0 0);
125 | --sidebar-accent: oklch(0.269 0 0);
126 | --sidebar-accent-foreground: oklch(0.985 0 0);
127 | --sidebar-border: oklch(0.269 0 0);
128 | --sidebar-ring: oklch(0.439 0 0);
129 | }
130 |
131 | @theme inline {
132 | --color-background: var(--background);
133 | --color-foreground: var(--foreground);
134 | --color-card: var(--card);
135 | --color-card-foreground: var(--card-foreground);
136 | --color-popover: var(--popover);
137 | --color-popover-foreground: var(--popover-foreground);
138 | --color-primary: var(--primary);
139 | --color-primary-foreground: var(--primary-foreground);
140 | --color-secondary: var(--secondary);
141 | --color-secondary-foreground: var(--secondary-foreground);
142 | --color-muted: var(--muted);
143 | --color-muted-foreground: var(--muted-foreground);
144 | --color-accent: var(--accent);
145 | --color-accent-foreground: var(--accent-foreground);
146 | --color-destructive: var(--destructive);
147 | --color-destructive-foreground: var(--destructive-foreground);
148 | --color-border: var(--border);
149 | --color-input: var(--input);
150 | --color-ring: var(--ring);
151 | --color-chart-1: var(--chart-1);
152 | --color-chart-2: var(--chart-2);
153 | --color-chart-3: var(--chart-3);
154 | --color-chart-4: var(--chart-4);
155 | --color-chart-5: var(--chart-5);
156 | --radius-sm: calc(var(--radius) - 4px);
157 | --radius-md: calc(var(--radius) - 2px);
158 | --radius-lg: var(--radius);
159 | --radius-xl: calc(var(--radius) + 4px);
160 | --color-sidebar: var(--sidebar);
161 | --color-sidebar-foreground: var(--sidebar-foreground);
162 | --color-sidebar-primary: var(--sidebar-primary);
163 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
164 | --color-sidebar-accent: var(--sidebar-accent);
165 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
166 | --color-sidebar-border: var(--sidebar-border);
167 | --color-sidebar-ring: var(--sidebar-ring);
168 | }
169 |
170 | @layer base {
171 | * {
172 | @apply border-border outline-ring/50;
173 | }
174 | body {
175 | @apply bg-background text-foreground;
176 | }
177 |
178 | /* Base scrollbar styling */
179 | * {
180 | scrollbar-width: thin; /* Firefox */
181 | }
182 |
183 | /* Webkit scrollbar styling (Chrome, Safari, Edge) */
184 | ::-webkit-scrollbar {
185 | width: 10px;
186 | height: 10px;
187 | }
188 |
189 | ::-webkit-scrollbar-track {
190 | background: var(--muted);
191 | border-radius: var(--radius-sm);
192 | }
193 |
194 | ::-webkit-scrollbar-thumb {
195 | background: var(--muted-foreground);
196 | border-radius: var(--radius-sm);
197 | border: 2px solid var(--muted);
198 | }
199 |
200 | ::-webkit-scrollbar-thumb:hover {
201 | background: var(--ring);
202 | }
203 |
204 | ::-webkit-scrollbar-corner {
205 | background: var(--muted);
206 | }
207 |
208 | /* Firefox specific styling */
209 | * {
210 | scrollbar-color: var(--muted-foreground) var(--muted);
211 | }
212 |
213 | .scrollable {
214 | overflow: auto;
215 | height: 100%;
216 | padding-right: 2px; /* Prevent content from bumping against scrollbar */
217 | }
218 |
219 | html,
220 | body {
221 | height: 100%;
222 | }
223 |
224 | .scroll-y {
225 | overflow-y: auto;
226 | overflow-x: hidden;
227 | }
228 |
229 | .scroll-x {
230 | overflow-x: auto;
231 | overflow-y: hidden;
232 | }
233 |
234 | .scroll-hidden {
235 | -ms-overflow-style: none; /* IE and Edge */
236 | scrollbar-width: none; /* Firefox */
237 | }
238 |
239 | .scroll-hidden::-webkit-scrollbar {
240 | display: none; /* Chrome, Safari, Opera */
241 | }
242 | }
243 |
244 | html,
245 | body {
246 | height: 100%;
247 | overflow: hidden;
248 | position: relative;
249 | margin: 0;
250 | padding: 0;
251 | }
252 |
253 | @supports (height: 100dvh) {
254 | .max-h-custom-dvh {
255 | max-height: calc(100dvh - 5rem);
256 | }
257 | }
258 |
259 | @supports not (height: 100dvh) {
260 | .max-h-custom-dvh {
261 | max-height: calc(100vh - 5rem);
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
3 | // import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | function DropdownMenu({
8 | ...props
9 | }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | // function DropdownMenuPortal({
14 | // ...props
15 | // }: React.ComponentProps) {
16 | // return (
17 | //
18 | // );
19 | // }
20 |
21 | function DropdownMenuTrigger({
22 | ...props
23 | }: React.ComponentProps) {
24 | return (
25 |
29 | );
30 | }
31 |
32 | function DropdownMenuContent({
33 | className,
34 | sideOffset = 4,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
39 |
48 |
49 | );
50 | }
51 |
52 | // function DropdownMenuGroup({
53 | // ...props
54 | // }: React.ComponentProps) {
55 | // return (
56 | //
57 | // );
58 | // }
59 |
60 | function DropdownMenuItem({
61 | className,
62 | inset,
63 | variant = "default",
64 | ...props
65 | }: React.ComponentProps & {
66 | inset?: boolean;
67 | variant?: "default" | "destructive";
68 | }) {
69 | return (
70 |
80 | );
81 | }
82 |
83 | // function DropdownMenuCheckboxItem({
84 | // className,
85 | // children,
86 | // checked,
87 | // ...props
88 | // }: React.ComponentProps) {
89 | // return (
90 | //
99 | //
100 | //
101 | //
102 | //
103 | //
104 | // {children}
105 | //
106 | // );
107 | // }
108 |
109 | // function DropdownMenuRadioGroup({
110 | // ...props
111 | // }: React.ComponentProps) {
112 | // return (
113 | //
117 | // );
118 | // }
119 |
120 | // function DropdownMenuRadioItem({
121 | // className,
122 | // children,
123 | // ...props
124 | // }: React.ComponentProps) {
125 | // return (
126 | //
134 | //
135 | //
136 | //
137 | //
138 | //
139 | // {children}
140 | //
141 | // );
142 | // }
143 |
144 | // function DropdownMenuLabel({
145 | // className,
146 | // inset,
147 | // ...props
148 | // }: React.ComponentProps & {
149 | // inset?: boolean;
150 | // }) {
151 | // return (
152 | //
161 | // );
162 | // }
163 |
164 | // function DropdownMenuSeparator({
165 | // className,
166 | // ...props
167 | // }: React.ComponentProps) {
168 | // return (
169 | //
174 | // );
175 | // }
176 |
177 | // function DropdownMenuShortcut({
178 | // className,
179 | // ...props
180 | // }: React.ComponentProps<"span">) {
181 | // return (
182 | //
190 | // );
191 | // }
192 |
193 | // function DropdownMenuSub({
194 | // ...props
195 | // }: React.ComponentProps) {
196 | // return ;
197 | // }
198 |
199 | // function DropdownMenuSubTrigger({
200 | // className,
201 | // inset,
202 | // children,
203 | // ...props
204 | // }: React.ComponentProps & {
205 | // inset?: boolean;
206 | // }) {
207 | // return (
208 | //
217 | // {children}
218 | //
219 | //
220 | // );
221 | // }
222 |
223 | // function DropdownMenuSubContent({
224 | // className,
225 | // ...props
226 | // }: React.ComponentProps) {
227 | // return (
228 | //
236 | // );
237 | // }
238 |
239 | export {
240 | DropdownMenu,
241 | // DropdownMenuPortal,
242 | DropdownMenuTrigger,
243 | DropdownMenuContent,
244 | // DropdownMenuGroup,
245 | // DropdownMenuLabel,
246 | DropdownMenuItem
247 | // DropdownMenuCheckboxItem,
248 | // DropdownMenuRadioGroup,
249 | // DropdownMenuRadioItem,
250 | // DropdownMenuSeparator,
251 | // DropdownMenuShortcut,
252 | // DropdownMenuSub,
253 | // DropdownMenuSubTrigger,
254 | // DropdownMenuSubContent
255 | };
256 |
--------------------------------------------------------------------------------