diff --git a/src/App.tsx b/src/App.tsx index ee1dfb8..fdc3196 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { IconBattery2, IconBluetooth, + IconBrandGithub, IconHome, IconKeyboard, IconPointer, @@ -22,6 +23,7 @@ import { AppLayout } from "./layouts/AppLayout"; import { HomePage } from "./pages/HomePage"; import { BatteryPage } from "./pages/BatteryPage"; import { BLEConnectionsPage } from "./pages/BLEConnectionsPage"; +import { GitHubPage } from "./pages/GitHubPage"; import { KeymapPage } from "./pages/KeymapPage"; import { TrackballPage } from "./pages/TrackballPage"; import { SettingsPage } from "./pages/SettingsPage"; @@ -63,6 +65,12 @@ const tabs: TabItem[] = [ icon: , content: , }, + { + id: "github", + label: "GitHub", + icon: , + content: , + }, ]; function App() { diff --git a/src/hooks/useGitHub.ts b/src/hooks/useGitHub.ts new file mode 100644 index 0000000..d37666a --- /dev/null +++ b/src/hooks/useGitHub.ts @@ -0,0 +1,357 @@ +import { useState, useCallback, useEffect } from "react"; +import * as github from "../lib/github"; +import type { Keymap, BehaviorDefinition } from "./useKeymap"; +import { + patchKeymapFile, + generateDiff, + type DiffLine, +} from "../lib/keymapFileGenerator"; + +export interface GitHubState { + token: string | null; + user: github.GitHubUser | null; + repos: github.GitHubRepo[]; + selectedRepo: github.GitHubRepo | null; + keymapFiles: string[]; + selectedFile: string | null; + originalContent: string | null; + patchedContent: string | null; + diff: DiffLine[]; + isLoading: boolean; + error: string | null; + isDemo: boolean; +} + +export interface UseGitHubReturn extends GitHubState { + login: () => void; + logout: () => void; + selectRepo: (repo: github.GitHubRepo) => Promise; + selectFile: (path: string) => Promise; + commitChanges: ( + keymap: Keymap, + behaviors: Map, + ) => Promise; + updateDiff: ( + keymap: Keymap, + behaviors: Map, + ) => void; +} + +export const GITHUB_TOKEN_KEY = "github-oauth-token"; +const DEMO_TOKEN = "demo-token"; + +export const DEMO_USER: github.GitHubUser = { + login: "demo-user", + name: "Demo User", + avatar_url: "https://github.com/identicons/demo.png", +}; + +export const DEMO_REPOS: github.GitHubRepo[] = [ + { + id: 1, + name: "zmk-config", + full_name: "demo-user/zmk-config", + private: false, + default_branch: "main", + html_url: "https://github.com/demo-user/zmk-config", + }, + { + id: 2, + name: "zmk-config-dya", + full_name: "demo-user/zmk-config-dya", + private: true, + default_branch: "main", + html_url: "https://github.com/demo-user/zmk-config-dya", + }, +]; + +export const DEMO_KEYMAP_FILES = ["config/dya_dash.keymap"]; + +export const DEMO_KEYMAP_CONTENT = `#include +#include +#include + +/ { + keymap { + compatible = "zmk,keymap"; + + default_layer { + bindings = < + &kp Q &kp W &kp E &kp R &kp T &kp Y &kp U &kp I + &kp A &kp S &kp D &kp F &kp G &kp H &kp J &kp K + >; + }; + + lower_layer { + bindings = < + &trans &trans &trans &trans &trans &trans &trans &trans + &trans &trans &trans &trans &trans &trans &trans &trans + >; + }; + }; +}; +`; + +export function useGitHub(isDemo: boolean): UseGitHubReturn { + const [token, setToken] = useState(() => { + if (isDemo) return DEMO_TOKEN; + try { + return localStorage.getItem(GITHUB_TOKEN_KEY); + } catch { + return null; + } + }); + + const [user, setUser] = useState( + isDemo ? DEMO_USER : null, + ); + const [repos, setRepos] = useState( + isDemo ? DEMO_REPOS : [], + ); + const [selectedRepo, setSelectedRepo] = useState( + null, + ); + const [keymapFiles, setKeymapFiles] = useState( + isDemo ? DEMO_KEYMAP_FILES : [], + ); + const [selectedFile, setSelectedFile] = useState(null); + const [originalContent, setOriginalContent] = useState( + isDemo ? DEMO_KEYMAP_CONTENT : null, + ); + const [patchedContent, setPatchedContent] = useState(null); + const [diff, setDiff] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token || isDemo || user) return; + github + .getUser(token) + .then(setUser) + .catch((err: Error) => setError(err.message)); + }, [token, isDemo, user]); + + useEffect(() => { + if (!token || !user || isDemo || repos.length > 0) return; + github + .listRepos(token) + .then(setRepos) + .catch((err: Error) => setError(err.message)); + }, [token, user, isDemo, repos.length]); + + const login = useCallback(() => { + if (isDemo) return; + const loginUrl = github.getLoginUrl(); + const popup = window.open(loginUrl, "github-oauth", "width=600,height=700"); + if (!popup) { + setError("Failed to open login popup. Please allow popups."); + return; + } + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === "github-oauth-token") { + const newToken = event.data.token as string; + setToken(newToken); + try { + localStorage.setItem(GITHUB_TOKEN_KEY, newToken); + } catch { + // ignore storage errors + } + window.removeEventListener("message", handleMessage); + popup.close(); + setUser(null); + setRepos([]); + setSelectedRepo(null); + setKeymapFiles([]); + setSelectedFile(null); + setOriginalContent(null); + setPatchedContent(null); + setDiff([]); + } + }; + window.addEventListener("message", handleMessage); + }, [isDemo]); + + const logout = useCallback(() => { + if (isDemo) return; + setToken(null); + try { + localStorage.removeItem(GITHUB_TOKEN_KEY); + } catch { + // ignore storage errors + } + setUser(null); + setRepos([]); + setSelectedRepo(null); + setKeymapFiles([]); + setSelectedFile(null); + setOriginalContent(null); + setPatchedContent(null); + setDiff([]); + setError(null); + }, [isDemo]); + + const selectRepo = useCallback( + async (repo: github.GitHubRepo) => { + setSelectedRepo(repo); + setSelectedFile(null); + setOriginalContent(null); + setPatchedContent(null); + setDiff([]); + setError(null); + + if (isDemo) { + setKeymapFiles(DEMO_KEYMAP_FILES); + return; + } + + if (!token) return; + setIsLoading(true); + try { + const [owner, repoName] = repo.full_name.split("/"); + const files = await github.findKeymapFiles( + token, + owner, + repoName, + repo.default_branch, + ); + setKeymapFiles(files); + if (files.length === 0) { + setError("No .keymap files found in this repository."); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load keymap files", + ); + } finally { + setIsLoading(false); + } + }, + [token, isDemo], + ); + + const selectFile = useCallback( + async (path: string) => { + setSelectedFile(path); + setPatchedContent(null); + setDiff([]); + setError(null); + + if (isDemo) { + setOriginalContent(DEMO_KEYMAP_CONTENT); + return; + } + + if (!token || !selectedRepo) return; + setIsLoading(true); + try { + const [owner, repoName] = selectedRepo.full_name.split("/"); + const file = await github.getFileContents(token, owner, repoName, path); + const content = github.decodeFileContent(file.content); + setOriginalContent(content); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load file"); + } finally { + setIsLoading(false); + } + }, + [token, selectedRepo, isDemo], + ); + + const updateDiff = useCallback( + (keymap: Keymap, behaviors: Map) => { + if (!originalContent) return; + const patched = patchKeymapFile(originalContent, keymap, behaviors); + setPatchedContent(patched); + const diffLines = generateDiff(originalContent, patched); + setDiff(diffLines); + }, + [originalContent], + ); + + const commitChanges = useCallback( + async (keymap: Keymap, behaviors: Map) => { + if (isDemo) { + setError( + "Demo mode: commit is disabled. In real mode, this would create a PR on GitHub.", + ); + return; + } + + if (!token || !selectedRepo || !selectedFile || !originalContent) { + setError("Please select a repository and file first"); + return; + } + + const patched = patchKeymapFile(originalContent, keymap, behaviors); + const [owner, repoName] = selectedRepo.full_name.split("/"); + const timestamp = Date.now(); + const branchName = `dya-studio/keymap-update-${timestamp}`; + + setIsLoading(true); + setError(null); + try { + const file = await github.getFileContents( + token, + owner, + repoName, + selectedFile, + ); + await github.createBranch( + token, + owner, + repoName, + branchName, + selectedRepo.default_branch, + ); + await github.commitFile( + token, + owner, + repoName, + selectedFile, + patched, + "Update keymap from DYA Studio", + branchName, + file.sha, + ); + const pr = await github.createPullRequest( + token, + owner, + repoName, + "Update keymap from DYA Studio", + branchName, + selectedRepo.default_branch, + "This PR was created by DYA Studio with updated keymap bindings.", + ); + window.open(pr.html_url, "_blank"); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to commit changes", + ); + } finally { + setIsLoading(false); + } + }, + [token, selectedRepo, selectedFile, originalContent, isDemo], + ); + + return { + token, + user, + repos, + selectedRepo, + keymapFiles, + selectedFile, + originalContent, + patchedContent, + diff, + isLoading, + error, + isDemo, + login, + logout, + selectRepo, + selectFile, + commitChanges, + updateDiff, + }; +} diff --git a/src/lib/github.ts b/src/lib/github.ts new file mode 100644 index 0000000..e63275a --- /dev/null +++ b/src/lib/github.ts @@ -0,0 +1,170 @@ +export const GITHUB_API_BASE = "https://api.github.com"; + +export function getLoginUrl(): string { + return "/api/auth/github/login"; +} + +export interface GitHubUser { + login: string; + name: string | null; + avatar_url: string; +} + +export interface GitHubRepo { + id: number; + name: string; + full_name: string; + private: boolean; + default_branch: string; + html_url: string; +} + +export interface GitHubFileContent { + name: string; + path: string; + sha: string; + content: string; + encoding: string; +} + +export interface GitHubTreeItem { + path: string; + type: string; + sha: string; +} + +async function githubFetch( + token: string, + path: string, + options?: RequestInit, +): Promise { + const res = await fetch(`${GITHUB_API_BASE}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + ...(options?.headers ?? {}), + }, + }); + return res; +} + +export async function getUser(token: string): Promise { + const res = await githubFetch(token, "/user"); + if (!res.ok) throw new Error(`Failed to get user: ${res.status}`); + return res.json() as Promise; +} + +export async function listRepos(token: string): Promise { + const res = await githubFetch( + token, + "/user/repos?per_page=100&sort=updated&type=all", + ); + if (!res.ok) throw new Error(`Failed to list repos: ${res.status}`); + return res.json() as Promise; +} + +export async function getFileContents( + token: string, + owner: string, + repo: string, + path: string, + ref?: string, +): Promise { + const query = ref ? `?ref=${encodeURIComponent(ref)}` : ""; + const res = await githubFetch( + token, + `/repos/${owner}/${repo}/contents/${path}${query}`, + ); + if (!res.ok) throw new Error(`Failed to get file: ${res.status}`); + return res.json() as Promise; +} + +export async function findKeymapFiles( + token: string, + owner: string, + repo: string, + branch: string, +): Promise { + const res = await githubFetch( + token, + `/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, + ); + if (!res.ok) throw new Error(`Failed to get tree: ${res.status}`); + const data = (await res.json()) as { tree: GitHubTreeItem[] }; + return data.tree + .filter((item) => item.type === "blob" && item.path.endsWith(".keymap")) + .map((item) => item.path); +} + +export function decodeFileContent(content: string): string { + const cleaned = content.replace(/\n/g, ""); + return atob(cleaned); +} + +export async function createBranch( + token: string, + owner: string, + repo: string, + newBranch: string, + fromBranch: string, +): Promise { + const refRes = await githubFetch( + token, + `/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(fromBranch)}`, + ); + if (!refRes.ok) throw new Error(`Failed to get ref: ${refRes.status}`); + const refData = (await refRes.json()) as { object: { sha: string } }; + const sha = refData.object.sha; + + const res = await githubFetch(token, `/repos/${owner}/${repo}/git/refs`, { + method: "POST", + body: JSON.stringify({ ref: `refs/heads/${newBranch}`, sha }), + }); + if (!res.ok) throw new Error(`Failed to create branch: ${res.status}`); +} + +export async function commitFile( + token: string, + owner: string, + repo: string, + path: string, + content: string, + message: string, + branch: string, + sha: string, +): Promise { + const encoded = btoa( + new Uint8Array(new TextEncoder().encode(content)).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + const res = await githubFetch( + token, + `/repos/${owner}/${repo}/contents/${path}`, + { + method: "PUT", + body: JSON.stringify({ message, content: encoded, branch, sha }), + }, + ); + if (!res.ok) throw new Error(`Failed to commit file: ${res.status}`); +} + +export async function createPullRequest( + token: string, + owner: string, + repo: string, + title: string, + head: string, + base: string, + body: string, +): Promise<{ html_url: string }> { + const res = await githubFetch(token, `/repos/${owner}/${repo}/pulls`, { + method: "POST", + body: JSON.stringify({ title, head, base, body }), + }); + if (!res.ok) throw new Error(`Failed to create pull request: ${res.status}`); + return res.json() as Promise<{ html_url: string }>; +} diff --git a/src/lib/keymapFileGenerator.ts b/src/lib/keymapFileGenerator.ts new file mode 100644 index 0000000..51f6934 --- /dev/null +++ b/src/lib/keymapFileGenerator.ts @@ -0,0 +1,231 @@ +import type { + BehaviorDefinition, + Keymap, + BehaviorBinding, +} from "../hooks/useKeymap"; + +const SPECIAL_KEYS: Record = { + 0x28: "RET", + 0x29: "ESC", + 0x2a: "BSPC", + 0x2b: "TAB", + 0x2c: "SPACE", + 0x2d: "MINUS", + 0x2e: "EQUAL", + 0x2f: "LBKT", + 0x30: "RBKT", + 0x31: "BSLH", + 0x32: "NUHS", + 0x33: "SEMI", + 0x34: "SQT", + 0x35: "GRAVE", + 0x36: "COMMA", + 0x37: "DOT", + 0x38: "FSLH", + 0x39: "CAPS", + 0x46: "PSCRN", + 0x47: "SLCK", + 0x48: "PAUSE", + 0x49: "INS", + 0x4a: "HOME", + 0x4b: "PG_UP", + 0x4c: "DEL", + 0x4d: "END", + 0x4e: "PG_DN", + 0x4f: "RIGHT", + 0x50: "LEFT", + 0x51: "DOWN", + 0x52: "UP", + 0x53: "KP_NUM", + 0x54: "KP_SLASH", + 0x55: "KP_MULTIPLY", + 0x56: "KP_MINUS", + 0x57: "KP_PLUS", + 0x58: "KP_ENTER", + 0x59: "KP_N1", + 0x5a: "KP_N2", + 0x5b: "KP_N3", + 0x5c: "KP_N4", + 0x5d: "KP_N5", + 0x5e: "KP_N6", + 0x5f: "KP_N7", + 0x60: "KP_N8", + 0x61: "KP_N9", + 0x62: "KP_N0", + 0xe0: "LCTRL", + 0xe1: "LSHFT", + 0xe2: "LALT", + 0xe3: "LGUI", + 0xe4: "RCTRL", + 0xe5: "RSHFT", + 0xe6: "RALT", + 0xe7: "RGUI", +}; + +export function getZmkKeycodeName(hidCode: number): string { + if (hidCode >= 0x04 && hidCode <= 0x1d) { + return String.fromCharCode(65 + (hidCode - 0x04)); + } + if (hidCode >= 0x1e && hidCode <= 0x26) { + return `N${hidCode - 0x1d}`; + } + if (hidCode === 0x27) return "N0"; + if (hidCode >= 0x3a && hidCode <= 0x45) { + return `F${hidCode - 0x39}`; + } + if (hidCode >= 0x68 && hidCode <= 0x73) { + return `F${hidCode - 0x68 + 13}`; + } + if (SPECIAL_KEYS[hidCode]) return SPECIAL_KEYS[hidCode]; + return `0x${hidCode.toString(16).toUpperCase()}`; +} + +const BT_PARAMS: Record = { + 0: "BT_CLR", + 1: "BT_NXT", + 2: "BT_PRV", +}; + +export function bindingToZmk( + binding: BehaviorBinding, + behaviors: Map, +): string { + const behavior = behaviors.get(binding.behaviorId); + if (!behavior) return `&unknown`; + + const name = behavior.displayName.toLowerCase(); + + if ( + name === "trans" || + name === "transparent" || + behavior.displayName === "&trans" + ) + return "&trans"; + if (name === "none" || behavior.id === 6) return "&none"; + if (name === "bootloader") return "&bootloader"; + if (name === "sys_reset" || name === "reset") return "&sys_reset"; + + if ( + name === "kp" || + name === "key press" || + behavior.displayName === "Key Press" + ) { + return `&kp ${getZmkKeycodeName(binding.param1)}`; + } + + if (name === "bt" || name === "bluetooth") { + const p1 = binding.param1; + if (p1 <= 2) return `&bt ${BT_PARAMS[p1]}`; + return `&bt BT_SEL ${p1 - 3}`; + } + + if (name === "mo" || name === "momentary layer") { + return `&mo ${binding.param1}`; + } + + if (name === "lt" || name === "layer-tap") { + return `< ${binding.param1} ${getZmkKeycodeName(binding.param2)}`; + } + + if (name === "mt" || name === "mod-tap") { + return `&mt ${getZmkKeycodeName(binding.param1)} ${getZmkKeycodeName(binding.param2)}`; + } + + if (name === "to layer" || name === "to") { + return `&to ${binding.param1}`; + } + + if (name === "toggle layer" || name === "tog") { + return `&tog ${binding.param1}`; + } + + if (name === "sticky key" || name === "sk") { + return `&sk ${getZmkKeycodeName(binding.param1)}`; + } + + const id = behavior.displayName.toLowerCase().replace(/\s+/g, "_"); + return `&${id}`; +} + +export function patchKeymapFile( + content: string, + keymap: Keymap, + behaviors: Map, +): string { + const layers = keymap.layers; + let layerIndex = 0; + return content.replace(/bindings\s*=\s*<([^>]*)>/gs, (_match: string) => { + if (layerIndex >= layers.length) { + layerIndex++; + return _match; + } + const layer = layers[layerIndex++]; + const zmkBindings = layer.bindings.map((b) => bindingToZmk(b, behaviors)); + const lines: string[] = []; + for (let i = 0; i < zmkBindings.length; i += 8) { + lines.push(" " + zmkBindings.slice(i, i + 8).join(" ")); + } + return `bindings = <\n${lines.join("\n")}\n >`; + }); +} + +export interface DiffLine { + type: "unchanged" | "added" | "removed"; + content: string; + lineNumber: { old: number | null; new: number | null }; +} + +export function generateDiff(original: string, modified: string): DiffLine[] { + const oldLines = original.split("\n"); + const newLines = modified.split("\n"); + const result: DiffLine[] = []; + + const m = oldLines.length; + const n = newLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0), + ); + + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + if (oldLines[i] === newLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + + let i = 0, + j = 0; + let oldNum = 1, + newNum = 1; + + while (i < m || j < n) { + if (i < m && j < n && oldLines[i] === newLines[j]) { + result.push({ + type: "unchanged", + content: oldLines[i], + lineNumber: { old: oldNum++, new: newNum++ }, + }); + i++; + j++; + } else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) { + result.push({ + type: "added", + content: newLines[j], + lineNumber: { old: null, new: newNum++ }, + }); + j++; + } else { + result.push({ + type: "removed", + content: oldLines[i], + lineNumber: { old: oldNum++, new: null }, + }); + i++; + } + } + + return result; +} diff --git a/src/pages/GitHubPage.tsx b/src/pages/GitHubPage.tsx new file mode 100644 index 0000000..0cac4c4 --- /dev/null +++ b/src/pages/GitHubPage.tsx @@ -0,0 +1,284 @@ +import { useContext, useEffect } from "react"; +import { + IconBrandGithub, + IconLock, + IconFile, + IconGitPullRequest, +} from "@tabler/icons-react"; +import { ConnectionContext } from "../components/DeviceConnection"; +import { useGitHub } from "../hooks/useGitHub"; +import { useKeymap } from "../hooks/useKeymap"; +import type { DiffLine } from "../lib/keymapFileGenerator"; +import type { GitHubRepo } from "../lib/github"; + +function DiffViewer({ diff }: { diff: DiffLine[] }) { + const added = diff.filter((l) => l.type === "added").length; + const removed = diff.filter((l) => l.type === "removed").length; + + return ( +
+
+ +{added} added + -{removed} removed +
+
+ {diff.map((line, i) => ( +
+ + {line.lineNumber.old ?? ""} + + + {line.lineNumber.new ?? ""} + + + {line.type === "added" + ? "+" + : line.type === "removed" + ? "-" + : " "} + + {line.content} +
+ ))} +
+
+ ); +} + +export function GitHubPage() { + const connection = useContext(ConnectionContext); + const isDemo = Boolean(connection.deviceName?.includes("Demo")); + const gh = useGitHub(isDemo); + const { keymap, behaviors } = useKeymap(); + + useEffect(() => { + if (keymap && behaviors.size > 0 && gh.originalContent) { + gh.updateDiff(keymap, behaviors); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keymap, behaviors, gh.originalContent]); + + const handleRepoClick = (repo: GitHubRepo) => { + gh.selectRepo(repo); + }; + + const handleFileClick = (path: string) => { + gh.selectFile(path); + }; + + const handleCommit = () => { + if (!keymap || !behaviors) return; + gh.commitChanges(keymap, behaviors); + }; + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

+ GitHub Keymap Sync +

+

+ Sync your keymap configuration to GitHub +

+
+
+ +
+ {/* Demo Mode Banner */} + {isDemo && ( +
+

+ Demo Mode +

+

+ GitHub login is disabled in demo mode. Repository and file + selection is simulated with sample data. +

+
+ )} + + {/* Error Display */} + {gh.error && ( +
+

{gh.error}

+
+ )} + + {/* Auth Section */} +
+

+ Authentication +

+ {!gh.user ? ( +
+ {!isDemo && ( + + )} +
+ ) : ( +
+
+ {gh.user.login} +
+

+ {gh.user.name ?? gh.user.login} +

+

+ @{gh.user.login} +

+
+
+ {!isDemo && ( + + )} +
+ )} +
+ + {/* Repository Selector */} + {gh.repos.length > 0 && ( +
+

+ Select Repository +

+
+ {gh.repos.map((repo) => ( + + ))} +
+
+ )} + + {/* Loading */} + {gh.isLoading && ( +
+

+ Loading... +

+
+ )} + + {/* Keymap File Selector */} + {gh.selectedRepo && gh.keymapFiles.length > 0 && ( +
+

+ Select Keymap File +

+
+ {gh.keymapFiles.map((path) => ( + + ))} +
+
+ )} + + {/* Diff Section */} + {gh.selectedFile && gh.originalContent && ( +
+
+

+ Keymap Changes +

+
+ {gh.diff.length > 0 && ( + + )} +
+
+ + {gh.diff.length > 0 ? ( + + ) : ( +

+ No changes detected. Connect keyboard and load keymap to see + differences. +

+ )} +
+ )} +
+ + {/* Info Box */} +
+

+ GitHub Keymap Sync allows you to export your current keyboard + configuration as a pull request to your ZMK config repository. + Connect your keyboard, select a repository and keymap file, then + create a PR with the updated bindings. +

+
+
+
+ ); +} diff --git a/src/pages/__tests__/GitHubPage.test.tsx b/src/pages/__tests__/GitHubPage.test.tsx new file mode 100644 index 0000000..ae0ab25 --- /dev/null +++ b/src/pages/__tests__/GitHubPage.test.tsx @@ -0,0 +1,282 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { GitHubPage } from "../GitHubPage"; +import { ConnectionContext } from "../../components/DeviceConnection"; +import { + ZMKAppProvider, + createMockZMKApp, +} from "@cormoran/zmk-studio-react-hook/testing"; + +jest.mock("../../hooks/useGitHub"); +import { useGitHub } from "../../hooks/useGitHub"; +const mockUseGitHub = useGitHub as jest.MockedFunction; + +jest.mock("../../hooks/useKeymap"); +import { useKeymap } from "../../hooks/useKeymap"; +const mockUseKeymap = useKeymap as jest.MockedFunction; + +const mockConnectionContext = { + isConnected: true, + deviceName: "DYA Keyboard", + onConnect: jest.fn(), + onDisconnect: jest.fn(), + isLoading: false, + error: null, +}; + +const demoConnectionContext = { + ...mockConnectionContext, + deviceName: "DYA Keyboard (Demo)", +}; + +const baseGitHubState = { + token: null, + user: null, + repos: [], + selectedRepo: null, + keymapFiles: [], + selectedFile: null, + originalContent: null, + patchedContent: null, + diff: [], + isLoading: false, + error: null, + isDemo: false, + login: jest.fn(), + logout: jest.fn(), + selectRepo: jest.fn(), + selectFile: jest.fn(), + commitChanges: jest.fn(), + updateDiff: jest.fn(), +}; + +const baseKeymapState = { + physicalLayouts: null, + keymap: null, + behaviors: new Map(), + originalBindings: new Map(), + hasUnsavedChanges: false, + isLoading: false, + error: null, + unlockRequired: false, + loadKeymapData: jest.fn(), + setBinding: jest.fn(), + resetBinding: jest.fn(), + moveLayer: jest.fn(), + addLayer: jest.fn(), + removeLayer: jest.fn(), + restoreLayer: jest.fn(), + availableLayers: 4, + removedLayerIds: [], + saveChanges: jest.fn(), + discardChanges: jest.fn(), + setActiveLayout: jest.fn(), + getOriginalBinding: jest.fn(), + isBindingModified: jest.fn(), + getBehavior: jest.fn(), + getBindingDisplayName: jest.fn(), + clearUnlockRequired: jest.fn(), +}; + +describe("GitHubPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseGitHub.mockReturnValue({ ...baseGitHubState }); + mockUseKeymap.mockReturnValue({ ...baseKeymapState }); + }); + + const renderComponent = (connectionOverrides = {}, githubOverrides = {}) => { + const connectionContext = { + ...mockConnectionContext, + ...connectionOverrides, + }; + mockUseGitHub.mockReturnValue({ ...baseGitHubState, ...githubOverrides }); + const mockZMKApp = createMockZMKApp(); + + return render( + + + + + , + ); + }; + + it("renders header correctly", () => { + renderComponent(); + expect(screen.getByText("GitHub Keymap Sync")).toBeInTheDocument(); + expect( + screen.getByText("Sync your keymap configuration to GitHub"), + ).toBeInTheDocument(); + }); + + it("shows demo mode banner in demo mode", () => { + renderComponent( + { deviceName: demoConnectionContext.deviceName }, + { isDemo: true }, + ); + expect(screen.getByText("Demo Mode")).toBeInTheDocument(); + expect( + screen.getByText(/GitHub login is disabled in demo mode/), + ).toBeInTheDocument(); + }); + + it("shows login button when not logged in and not demo", () => { + renderComponent({}, { token: null, user: null, isDemo: false }); + expect( + screen.getByRole("button", { name: /Login with GitHub/i }), + ).toBeInTheDocument(); + }); + + it("shows user info when logged in", () => { + renderComponent( + {}, + { + token: "test-token", + user: { + login: "testuser", + name: "Test User", + avatar_url: "https://example.com/avatar.png", + }, + }, + ); + expect(screen.getByText("Test User")).toBeInTheDocument(); + expect(screen.getByText("@testuser")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Logout/i })).toBeInTheDocument(); + }); + + it("shows repo list when repos are available", () => { + renderComponent( + {}, + { + token: "test-token", + user: { login: "testuser", name: "Test User", avatar_url: "" }, + repos: [ + { + id: 1, + name: "zmk-config", + full_name: "testuser/zmk-config", + private: false, + default_branch: "main", + html_url: "https://github.com/testuser/zmk-config", + }, + { + id: 2, + name: "zmk-private", + full_name: "testuser/zmk-private", + private: true, + default_branch: "main", + html_url: "https://github.com/testuser/zmk-private", + }, + ], + }, + ); + expect(screen.getByText("zmk-config")).toBeInTheDocument(); + expect(screen.getByText("zmk-private")).toBeInTheDocument(); + }); + + it("shows keymap file list when repo is selected and files are loaded", () => { + renderComponent( + {}, + { + token: "test-token", + user: { login: "testuser", name: "Test User", avatar_url: "" }, + repos: [ + { + id: 1, + name: "zmk-config", + full_name: "testuser/zmk-config", + private: false, + default_branch: "main", + html_url: "", + }, + ], + selectedRepo: { + id: 1, + name: "zmk-config", + full_name: "testuser/zmk-config", + private: false, + default_branch: "main", + html_url: "", + }, + keymapFiles: ["config/dya.keymap"], + }, + ); + expect(screen.getByText("config/dya.keymap")).toBeInTheDocument(); + }); + + it("shows diff viewer when diff data is available", () => { + renderComponent( + {}, + { + token: "test-token", + user: { login: "testuser", name: "Test User", avatar_url: "" }, + selectedRepo: { + id: 1, + name: "zmk-config", + full_name: "testuser/zmk-config", + private: false, + default_branch: "main", + html_url: "", + }, + selectedFile: "config/dya.keymap", + originalContent: "&kp A", + diff: [ + { + type: "removed", + content: "&kp A", + lineNumber: { old: 1, new: null }, + }, + { + type: "added", + content: "&kp B", + lineNumber: { old: null, new: 1 }, + }, + ], + }, + ); + expect(screen.getByText("+1 added")).toBeInTheDocument(); + expect(screen.getByText("-1 removed")).toBeInTheDocument(); + }); + + it("shows PR button when diff is available", () => { + renderComponent( + {}, + { + token: "test-token", + user: { login: "testuser", name: "Test User", avatar_url: "" }, + selectedRepo: { + id: 1, + name: "zmk-config", + full_name: "testuser/zmk-config", + private: false, + default_branch: "main", + html_url: "", + }, + selectedFile: "config/dya.keymap", + originalContent: "&kp A", + diff: [ + { + type: "added", + content: "&kp B", + lineNumber: { old: null, new: 1 }, + }, + ], + keymapFiles: ["config/dya.keymap"], + }, + ); + expect( + screen.getByRole("button", { name: /Create Pull Request/i }), + ).toBeInTheDocument(); + }); + + it("calls login when login button is clicked", async () => { + const user = userEvent.setup(); + const mockLogin = jest.fn(); + renderComponent({}, { login: mockLogin, isDemo: false }); + await user.click( + screen.getByRole("button", { name: /Login with GitHub/i }), + ); + expect(mockLogin).toHaveBeenCalled(); + }); +}); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..3944888 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,95 @@ +interface Env { + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + ASSETS: { fetch: (req: Request) => Promise }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/api/auth/github/login") { + const state = crypto.randomUUID(); + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID, + scope: "repo read:user", + state, + redirect_uri: `${url.origin}/api/auth/github/callback`, + }); + const githubUrl = `https://github.com/login/oauth/authorize?${params}`; + return new Response(null, { + status: 302, + headers: { + Location: githubUrl, + "Set-Cookie": `github_oauth_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`, + }, + }); + } + + if (url.pathname === "/api/auth/github/callback") { + const code = url.searchParams.get("code"); + const stateParam = url.searchParams.get("state"); + if (!code) { + return new Response("Missing code", { status: 400 }); + } + + const cookieHeader = request.headers.get("Cookie") ?? ""; + const cookieState = cookieHeader + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith("github_oauth_state=")) + ?.split("=")[1]; + + if (!stateParam || !cookieState || stateParam !== cookieState) { + return new Response("Invalid state parameter", { status: 400 }); + } + + const tokenRes = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + }), + }, + ); + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + error?: string; + }; + + if (!tokenData.access_token) { + return new Response("Failed to get access token", { status: 400 }); + } + + const token = tokenData.access_token; + const origin = url.origin; + const html = ` + +GitHub Login + +

Login successful, you can close this window.

+ + +`; + + return new Response(html, { + headers: { "Content-Type": "text/html" }, + }); + } + + return env.ASSETS.fetch(request); + }, +}; diff --git a/wrangler.toml b/wrangler.toml index ab21f94..344e873 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,5 @@ compatibility_date = "2024-06-11" +main = "src/worker.ts" assets = { directory = "./dist" } [env.release]