diff --git a/package-lock.json b/package-lock.json index 8b40e16..bbe8b67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@lucide/svelte": "^1.21.0", "@tailwindcss/vite": "^4.3.1", "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" @@ -1427,6 +1428,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index 4960ce8..82a52d7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@lucide/svelte": "^1.21.0", "@tailwindcss/vite": "^4.3.1", "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.7.1", "simple-icons": "^16.24.1", "svelte": "^5.0.0", "tailwindcss": "^4.3.1" diff --git a/scripts/generate-icon.mjs b/scripts/generate-icon.mjs new file mode 100644 index 0000000..595878a --- /dev/null +++ b/scripts/generate-icon.mjs @@ -0,0 +1,326 @@ +import { Buffer } from "node:buffer"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync } from "node:zlib"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const iconsDir = join(root, "src-tauri", "icons"); + +const icoSizes = [16, 24, 32, 48, 64, 128, 256]; + +function clamp(value, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); +} + +function smoothstep(edge0, edge1, value) { + const t = clamp((value - edge0) / (edge1 - edge0)); + return t * t * (3 - 2 * t); +} + +function hex(value) { + const clean = value.replace("#", ""); + return { + r: Number.parseInt(clean.slice(0, 2), 16), + g: Number.parseInt(clean.slice(2, 4), 16), + b: Number.parseInt(clean.slice(4, 6), 16), + }; +} + +function mix(a, b, t) { + const u = clamp(t); + return { + r: a.r + (b.r - a.r) * u, + g: a.g + (b.g - a.g) * u, + b: a.b + (b.b - a.b) * u, + }; +} + +function addGlow(color, glow, amount) { + return { + r: clamp(color.r + glow.r * amount, 0, 255), + g: clamp(color.g + glow.g * amount, 0, 255), + b: clamp(color.b + glow.b * amount, 0, 255), + }; +} + +function blend(base, layer) { + const alpha = clamp(layer.a); + const outA = alpha + base.a * (1 - alpha); + if (outA <= 0) return { r: 0, g: 0, b: 0, a: 0 }; + + return { + r: (layer.r * alpha + base.r * base.a * (1 - alpha)) / outA, + g: (layer.g * alpha + base.g * base.a * (1 - alpha)) / outA, + b: (layer.b * alpha + base.b * base.a * (1 - alpha)) / outA, + a: outA, + }; +} + +function roundedRectDistance(x, y, cx, cy, hx, hy, radius) { + const qx = Math.abs(x - cx) - hx + radius; + const qy = Math.abs(y - cy) - hy + radius; + const outsideX = Math.max(qx, 0); + const outsideY = Math.max(qy, 0); + return Math.hypot(outsideX, outsideY) + Math.min(Math.max(qx, qy), 0) - radius; +} + +function segmentDistance(px, py, ax, ay, bx, by) { + const vx = bx - ax; + const vy = by - ay; + const wx = px - ax; + const wy = py - ay; + const lenSq = vx * vx + vy * vy; + const t = lenSq === 0 ? 0 : clamp((wx * vx + wy * vy) / lenSq); + return Math.hypot(px - (ax + vx * t), py - (ay + vy * t)); +} + +function polygonContains(x, y, points) { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const xi = points[i][0]; + const yi = points[i][1]; + const xj = points[j][0]; + const yj = points[j][1]; + const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersects) inside = !inside; + } + return inside; +} + +function polygonDistance(x, y, points) { + let distance = Infinity; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + distance = Math.min(distance, segmentDistance(x, y, a[0], a[1], b[0], b[1])); + } + return polygonContains(x, y, points) ? -distance : distance; +} + +function drawStroke(color, x, y, ax, ay, bx, by, radius, strokeColor, alpha = 1) { + const d = segmentDistance(x, y, ax, ay, bx, by); + const a = smoothstep(radius + 1.4, radius - 0.8, d) * alpha; + if (a <= 0) return color; + return blend(color, { ...strokeColor, a }); +} + +function drawCircle(color, x, y, cx, cy, radius, circleColor, alpha = 1) { + const d = Math.hypot(x - cx, y - cy); + const a = smoothstep(radius + 1.4, radius - 0.8, d) * alpha; + if (a <= 0) return color; + return blend(color, { ...circleColor, a }); +} + +function sampleScene(x, y) { + const dark = hex("#10111f"); + const purple = hex("#bd34fe"); + const indigo = hex("#646cff"); + const cyan = hex("#41d1ff"); + const yellow = hex("#ffd343"); + const white = hex("#f7fbff"); + + let color = { r: 0, g: 0, b: 0, a: 0 }; + const badge = roundedRectDistance(x, y, 128, 128, 108, 108, 48); + + const shadowA = Math.exp(-Math.max(badge, 0) / 12) * 0.22; + if (badge > 0 && shadowA > 0.005) { + color = blend(color, { r: 0, g: 0, b: 0, a: shadowA }); + } + + const badgeA = smoothstep(1.6, -1.2, badge); + if (badgeA > 0) { + const diagonal = (x + y) / 512; + let base = mix(dark, indigo, diagonal * 0.75); + + const purpleGlow = Math.exp(-((x - 68) ** 2 + (y - 62) ** 2) / 6200); + const cyanGlow = Math.exp(-((x - 184) ** 2 + (y - 72) ** 2) / 5600); + const yellowGlow = Math.exp(-((x - 184) ** 2 + (y - 202) ** 2) / 8400); + + base = addGlow(base, purple, purpleGlow * 0.58); + base = addGlow(base, cyan, cyanGlow * 0.55); + base = addGlow(base, yellow, yellowGlow * 0.22); + + color = blend(color, { ...base, a: badgeA }); + + const rim = smoothstep(4.2, 0.4, Math.abs(badge)); + color = blend(color, { ...white, a: rim * 0.18 * badgeA }); + } + + const bolt = [ + [144, 40], + [198, 40], + [164, 111], + [207, 111], + [113, 218], + [143, 142], + [103, 142], + ]; + const boltD = polygonDistance(x, y, bolt); + const boltA = smoothstep(2.5, -1, boltD); + if (boltA > 0) { + color = blend(color, { ...yellow, a: boltA * 0.24 }); + } + + const glow = hex("#41d1ff"); + const line = hex("#eef7ff"); + const nodeFill = hex("#131827"); + + for (const segment of [ + [82, 73, 82, 181], + [82, 116, 166, 116], + ]) { + color = drawStroke(color, x, y, ...segment, 12, glow, 0.24); + color = drawStroke(color, x, y, ...segment, 5.2, line, 0.94); + } + + for (const [cx, cy, accent] of [ + [82, 73, cyan], + [166, 116, purple], + [82, 181, yellow], + ]) { + color = drawCircle(color, x, y, cx, cy, 22, accent, 0.25); + color = drawCircle(color, x, y, cx, cy, 14.5, white, 0.96); + color = drawCircle(color, x, y, cx, cy, 8.4, nodeFill, 1); + color = drawCircle(color, x, y, cx, cy, 4.2, accent, 0.95); + } + + const spark = [ + [186, 161], + [195, 181], + [215, 190], + [195, 199], + [186, 219], + [177, 199], + [157, 190], + [177, 181], + ]; + const sparkD = polygonDistance(x, y, spark); + const sparkA = smoothstep(1.8, -0.8, sparkD); + if (sparkA > 0) { + color = blend(color, { ...yellow, a: sparkA * 0.92 }); + } + + return color; +} + +function renderPng(size) { + const scale = 256 / size; + const samples = size <= 32 ? 5 : 3; + const data = Buffer.alloc(size * size * 4); + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + + for (let sy = 0; sy < samples; sy += 1) { + for (let sx = 0; sx < samples; sx += 1) { + const scene = sampleScene((x + (sx + 0.5) / samples) * scale, (y + (sy + 0.5) / samples) * scale); + r += scene.r * scene.a; + g += scene.g * scene.a; + b += scene.b * scene.a; + a += scene.a; + } + } + + const total = samples * samples; + const alpha = a / total; + const offset = (y * size + x) * 4; + data[offset] = alpha > 0 ? Math.round(r / a) : 0; + data[offset + 1] = alpha > 0 ? Math.round(g / a) : 0; + data[offset + 2] = alpha > 0 ? Math.round(b / a) : 0; + data[offset + 3] = Math.round(alpha * 255); + } + } + + return encodePng(size, size, data); +} + +const crcTable = new Uint32Array(256); +for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + crcTable[n] = c >>> 0; +} + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, payload) { + const typeBytes = Buffer.from(type); + const out = Buffer.alloc(12 + payload.length); + out.writeUInt32BE(payload.length, 0); + typeBytes.copy(out, 4); + payload.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4); + return out; +} + +function encodePng(width, height, rgba) { + const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + + const raw = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y += 1) { + const rowStart = y * (width * 4 + 1); + raw[rowStart] = 0; + rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4); + } + + return Buffer.concat([ + header, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function makeIco(images) { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(images.length, 4); + + const entries = Buffer.alloc(images.length * 16); + let offset = 6 + images.length * 16; + + images.forEach((image, index) => { + const entry = index * 16; + entries[entry] = image.size >= 256 ? 0 : image.size; + entries[entry + 1] = image.size >= 256 ? 0 : image.size; + entries[entry + 2] = 0; + entries[entry + 3] = 0; + entries.writeUInt16LE(1, entry + 4); + entries.writeUInt16LE(32, entry + 6); + entries.writeUInt32LE(image.png.length, entry + 8); + entries.writeUInt32LE(offset, entry + 12); + offset += image.png.length; + }); + + return Buffer.concat([header, entries, ...images.map((image) => image.png)]); +} + +mkdirSync(iconsDir, { recursive: true }); + +const iconPng = renderPng(512); +const icoImages = icoSizes.map((size) => ({ size, png: renderPng(size) })); + +writeFileSync(join(iconsDir, "icon.png"), iconPng); +writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoImages)); + +console.log(`Wrote ${join("src-tauri", "icons", "icon.png")} (${iconPng.length} bytes)`); +console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoImages.length} sizes)`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2411c53..106f85d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1932,6 +1932,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2396,6 +2397,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3040,6 +3065,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3147,6 +3230,7 @@ dependencies = [ "serde", "tauri", "tauri-build", + "tauri-plugin-dialog", ] [[package]] @@ -4046,6 +4130,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4079,13 +4172,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4116,6 +4226,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4128,6 +4244,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4140,12 +4262,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4158,6 +4292,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4170,6 +4310,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4182,6 +4328,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4194,6 +4346,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 60f44c1..c7747ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ build = "build.rs" [dependencies] serde = { version = "1", features = ["derive"] } tauri = { version = "2", features = [] } +tauri-plugin-dialog = "=2.7.0" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b7efc2c..c74b012 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-unminimize", "core:window:allow-close", "core:window:allow-is-maximized", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "dialog:allow-open" ] } diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b7bf72d..6b2c758 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 3d0c8fe..bb0ffd8 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 7947b3c..4b326fe 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,9 +1,15 @@ use serde::Serialize; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, ffi::{OsStr, OsString}, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -102,6 +108,21 @@ pub struct GitRepositoryFile { pub status: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitSearchHit { + pub commit_hash: String, + pub short_hash: String, + pub summary: String, + pub author_name: String, + pub author_email: String, + pub date: String, + pub file: String, + pub old_file: Option, + pub line_number: Option, + pub line: String, + pub matches_added: u32, +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] struct BranchInfo { current_branch: Option, @@ -118,6 +139,54 @@ enum CheckoutPlan { } const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; +const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen."; +static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Default, Clone)] +pub struct SearchCancellationState { + cancelled: Arc>>, +} + +impl SearchCancellationState { + fn cancel(&self, search_id: &str) -> Result<(), String> { + self.cancelled + .lock() + .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .insert(search_id.to_string()); + Ok(()) + } + + fn clear(&self, search_id: &str) -> Result<(), String> { + self.cancelled + .lock() + .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .remove(search_id); + Ok(()) + } + + fn is_cancelled(&self, search_id: &str) -> Result { + Ok(self + .cancelled + .lock() + .map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())? + .contains(search_id)) + } +} + +#[derive(Clone)] +struct SearchCancellation { + state: SearchCancellationState, + search_id: String, +} + +fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> { + if let Some(cancellation) = cancellation { + if cancellation.state.is_cancelled(&cancellation.search_id)? { + return Err(SEARCH_CANCELLED_MESSAGE.to_string()); + } + } + Ok(()) +} #[tauri::command] pub fn open_repository(path: String) -> Result { @@ -394,6 +463,174 @@ pub fn list_file_history( parse_commit_log(&repo, &output) } +#[tauri::command] +pub async fn search_code_introductions( + path: String, + query: String, + case_sensitive: Option, + limit: Option, + search_id: Option, + state: tauri::State<'_, SearchCancellationState>, +) -> Result, String> { + let state = state.inner().clone(); + + tauri::async_runtime::spawn_blocking(move || { + let repo = resolve_repo(&path)?; + let query = normalize_newlines(&query); + let query = query.trim_matches('\n').to_string(); + if query.trim().is_empty() { + return Err("Suchtext darf nicht leer sein.".to_string()); + } + + if verify_commit(&repo, "HEAD").is_err() { + return Ok(Vec::new()); + } + + let case_sensitive = case_sensitive.unwrap_or(false); + let limit = limit.unwrap_or(250).clamp(1, 1000) as usize; + let search_id = search_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + + let cancellation = search_id.as_ref().map(|search_id| SearchCancellation { + state: state.clone(), + search_id: search_id.clone(), + }); + + let result = search_code_introductions_core( + &repo, + query, + case_sensitive, + limit, + cancellation.as_ref(), + ); + + if let Some(search_id) = search_id.as_deref() { + let _ = state.clear(search_id); + } + + result + }) + .await + .map_err(|err| format!("Such-Task konnte nicht abgeschlossen werden: {err}"))? +} + +#[tauri::command] +pub fn cancel_code_search( + search_id: String, + state: tauri::State<'_, SearchCancellationState>, +) -> Result<(), String> { + let search_id = search_id.trim(); + if search_id.is_empty() { + return Ok(()); + } + + state.cancel(search_id) +} + +fn search_code_introductions_core( + repo: &Path, + query: String, + case_sensitive: bool, + limit: usize, + cancellation: Option<&SearchCancellation>, +) -> Result, String> { + check_search_cancelled(cancellation)?; + let candidates = search_candidate_commits(&repo, &query, case_sensitive, cancellation)?; + let mut hits = Vec::new(); + + for commit in candidates { + check_search_cancelled(cancellation)?; + if hits.len() >= limit { + break; + } + + let files = commit_files(&repo, &commit)?; + if files.is_empty() { + continue; + } + + let parents = commit_parents(&repo, &commit)?; + let mut metadata: Option = None; + + for file in files { + check_search_cancelled(cancellation)?; + if hits.len() >= limit { + break; + } + + if matches!(file.status, FileStatusKind::Deleted) { + continue; + } + + let Some(after_content) = read_text_blob(&repo, &commit, &file.path)? else { + continue; + }; + let after_count = count_matches(&after_content, &query, case_sensitive); + if after_count == 0 { + continue; + } + + let before_count = max_parent_match_count( + &repo, + &parents, + file.old_path.as_deref().unwrap_or(&file.path), + &query, + case_sensitive, + )?; + + if after_count <= before_count { + continue; + } + + let match_line = first_added_match_line( + &repo, + parents.first().map(String::as_str), + &commit, + &file.path, + &query, + case_sensitive, + cancellation, + )? + .or_else(|| first_match_line(&after_content, &query, case_sensitive)); + + let Some((line_number, line)) = match_line else { + continue; + }; + let info = metadata + .get_or_insert_with(|| { + commit_search_metadata(&repo, &commit).unwrap_or_else(|_| { + GitSearchCommitMetadata { + commit_hash: commit.clone(), + short_hash: short_hash(&commit), + author_name: String::new(), + author_email: String::new(), + date: String::new(), + summary: String::new(), + } + }) + }) + .clone(); + + hits.push(GitSearchHit { + commit_hash: info.commit_hash, + short_hash: info.short_hash, + summary: info.summary, + author_name: info.author_name, + author_email: info.author_email, + date: info.date, + file: file.path, + old_file: file.old_path, + line_number: Some(line_number), + line, + matches_added: after_count.saturating_sub(before_count) as u32, + }); + } + } + + Ok(hits) +} + #[tauri::command] pub fn restore_to_commit(path: String, commit: String) -> Result { let repo = resolve_repo(&path)?; @@ -770,6 +1007,265 @@ fn has_unresolved_conflicts(status: &GitStatus) -> bool { }) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GitSearchCommitMetadata { + commit_hash: String, + short_hash: String, + author_name: String, + author_email: String, + date: String, + summary: String, +} + +fn search_candidate_commits( + repo: &Path, + query: &str, + case_sensitive: bool, + cancellation: Option<&SearchCancellation>, +) -> Result, String> { + let output = if query.contains('\n') { + run_git_cancellable( + repo, + ["rev-list", "--all", "--reverse"], + cancellation, + "Git-Suche fehlgeschlagen", + )? + } else { + let mut args = vec![ + OsString::from("log"), + OsString::from("--all"), + OsString::from("--reverse"), + OsString::from("--format=%H"), + ]; + if !case_sensitive { + args.push(OsString::from("-i")); + } + args.push(OsString::from(format!("-S{query}"))); + run_git_cancellable(repo, args, cancellation, "Git-Suche fehlgeschlagen")? + }; + + Ok(String::from_utf8_lossy(&output) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) +} + +fn commit_parents(repo: &Path, commit: &str) -> Result, String> { + let output = run_git(repo, ["rev-list", "--parents", "-n", "1", commit])?; + let text = String::from_utf8_lossy(&output); + Ok(text + .split_whitespace() + .skip(1) + .map(ToString::to_string) + .collect()) +} + +fn max_parent_match_count( + repo: &Path, + parents: &[String], + file: &str, + query: &str, + case_sensitive: bool, +) -> Result { + let mut max_count = 0; + for parent in parents { + if let Some(content) = read_text_blob(repo, parent, file)? { + max_count = max_count.max(count_matches(&content, query, case_sensitive)); + } + } + Ok(max_count) +} + +fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result, String> { + let spec = format!("{commit}:{file}"); + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["show", spec.as_str()]) + .output() + .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; + + if !output.status.success() { + return Ok(None); + } + + if is_binary_bytes(&output.stdout) { + return Ok(None); + } + + Ok(Some(normalize_newlines(&String::from_utf8_lossy( + &output.stdout, + )))) +} + +fn normalize_newlines(value: &str) -> String { + value.replace("\r\n", "\n").replace('\r', "\n") +} + +fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize { + let content = if case_sensitive { + content.to_string() + } else { + content.to_ascii_lowercase() + }; + let query = if case_sensitive { + query.to_string() + } else { + query.to_ascii_lowercase() + }; + + if query.is_empty() { + return 0; + } + + let mut count = 0; + let mut start = 0; + while let Some(index) = content[start..].find(&query) { + count += 1; + start += index + query.len(); + } + count +} + +fn first_added_match_line( + repo: &Path, + parent: Option<&str>, + commit: &str, + file: &str, + query: &str, + case_sensitive: bool, + cancellation: Option<&SearchCancellation>, +) -> Result, String> { + let Some(parent) = parent else { + return Ok(None); + }; + + check_search_cancelled(cancellation)?; + let output = run_git_with_paths_cancellable( + repo, + &["diff", "--unified=0", parent, commit], + &[file.to_string()], + cancellation, + "Git-Diff fuer Suchtreffer fehlgeschlagen", + )?; + let patch = String::from_utf8_lossy(&output); + let line_query = first_query_line(query); + let mut new_line = 0u32; + + for line in patch.lines() { + check_search_cancelled(cancellation)?; + if line.starts_with("@@") { + if let Some(start) = parse_new_hunk_start(line) { + new_line = start; + } + continue; + } + + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") { + continue; + } + + if let Some(added) = line.strip_prefix('+') { + if count_matches(added, line_query, case_sensitive) > 0 { + return Ok(Some((new_line.max(1), compact_search_line(added)))); + } + new_line = new_line.saturating_add(1); + } else if line.starts_with('-') { + continue; + } else if line.starts_with(' ') { + new_line = new_line.saturating_add(1); + } + } + + Ok(None) +} + +fn first_query_line(query: &str) -> &str { + query + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or(query) +} + +fn parse_new_hunk_start(line: &str) -> Option { + let plus = line.split_whitespace().find(|part| part.starts_with('+'))?; + let number = plus + .trim_start_matches('+') + .split_once(',') + .map(|(start, _)| start) + .unwrap_or_else(|| plus.trim_start_matches('+')); + number.parse().ok() +} + +fn first_match_line(content: &str, query: &str, case_sensitive: bool) -> Option<(u32, String)> { + let haystack = if case_sensitive { + content.to_string() + } else { + content.to_ascii_lowercase() + }; + let needle = if case_sensitive { + query.to_string() + } else { + query.to_ascii_lowercase() + }; + let index = haystack.find(&needle)?; + let line_number = content[..index] + .bytes() + .filter(|byte| *byte == b'\n') + .count() as u32 + + 1; + let line_start = content[..index].rfind('\n').map(|pos| pos + 1).unwrap_or(0); + let line_end = content[index..] + .find('\n') + .map(|pos| index + pos) + .unwrap_or(content.len()); + Some(( + line_number, + compact_search_line(&content[line_start..line_end]), + )) +} + +fn compact_search_line(line: &str) -> String { + const MAX_LEN: usize = 240; + let compact = line.trim().replace('\t', " "); + if compact.chars().count() <= MAX_LEN { + return compact; + } + + let mut shortened: String = compact.chars().take(MAX_LEN).collect(); + shortened.push_str("..."); + shortened +} + +fn commit_search_metadata(repo: &Path, commit: &str) -> Result { + let output = run_git( + repo, + [ + "show", + "-s", + "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s", + commit, + ], + )?; + let text = String::from_utf8_lossy(&output); + let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect(); + if fields.len() != 6 { + return Err(format!("Unerwarteter Git-Commit-Eintrag: {text}")); + } + + Ok(GitSearchCommitMetadata { + commit_hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + author_name: fields[2].to_string(), + author_email: fields[3].to_string(), + date: fields[4].to_string(), + summary: fields[5].to_string(), + }) +} + fn parse_commit_log(repo: &Path, output: &[u8]) -> Result, String> { const FIELD_SEPARATOR: char = '\x1f'; const RECORD_SEPARATOR: char = '\x1e'; @@ -1283,6 +1779,20 @@ fn run_git_with_paths( run_git(repo, args) } +fn run_git_with_paths_cancellable( + repo: &Path, + base_args: &[&str], + files: &[String], + cancellation: Option<&SearchCancellation>, + context: &str, +) -> Result, String> { + let mut args = Vec::with_capacity(base_args.len() + files.len() + 1); + args.extend(base_args.iter().map(OsString::from)); + args.push(OsString::from("--")); + args.extend(files.iter().map(OsString::from)); + run_git_cancellable(repo, args, cancellation, context) +} + fn run_git(repo: &Path, args: I) -> Result, String> where I: IntoIterator, @@ -1291,6 +1801,91 @@ where run_git_at(repo, args, "Git-Befehl fehlgeschlagen") } +fn run_git_cancellable( + repo: &Path, + args: I, + cancellation: Option<&SearchCancellation>, + context: &str, +) -> Result, String> +where + I: IntoIterator, + S: AsRef, +{ + check_search_cancelled(cancellation)?; + + let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_dir = std::env::temp_dir(); + let stdout_path = temp_dir.join(format!( + "gitlite_search_{}_{}.out", + std::process::id(), + counter + )); + let stderr_path = temp_dir.join(format!( + "gitlite_search_{}_{}.err", + std::process::id(), + counter + )); + let stdout_file = std::fs::File::create(&stdout_path) + .map_err(|err| format!("Git-Ausgabedatei konnte nicht erstellt werden: {err}"))?; + let stderr_file = std::fs::File::create(&stderr_path) + .map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?; + + let mut child = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)) + .spawn() + .map_err(|err| { + let _ = std::fs::remove_file(&stdout_path); + let _ = std::fs::remove_file(&stderr_path); + format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}") + })?; + + let status = loop { + if let Err(err) = check_search_cancelled(cancellation) { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_file(&stdout_path); + let _ = std::fs::remove_file(&stderr_path); + return Err(err); + } + + if let Some(status) = child + .try_wait() + .map_err(|err| format!("Git-Prozess konnte nicht geprueft werden: {err}"))? + { + break status; + } + + thread::sleep(Duration::from_millis(60)); + }; + + let stdout = std::fs::read(&stdout_path) + .map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?; + let stderr = std::fs::read(&stderr_path) + .map_err(|err| format!("Git-Fehlerausgabe konnte nicht gelesen werden: {err}"))?; + let _ = std::fs::remove_file(&stdout_path); + let _ = std::fs::remove_file(&stderr_path); + + if status.success() { + return Ok(stdout); + } + + let stderr_text = String::from_utf8_lossy(&stderr); + let stdout_text = String::from_utf8_lossy(&stdout); + let details = if !stderr_text.trim().is_empty() { + stderr_text.trim() + } else if !stdout_text.trim().is_empty() { + stdout_text.trim() + } else { + "unbekannter Fehler" + }; + + Err(format!("{context}: {details}")) +} + fn run_git_at(path: &Path, args: I, context: &str) -> Result, String> where I: IntoIterator, @@ -1565,6 +2160,89 @@ mod tests { run_git_test(repo, ["commit", "-q", "-m", "init"]); } + #[test] + fn search_code_introductions_finds_added_string() { + let repo = init_temp_repo("search_added_string"); + fs::create_dir_all(repo.path.join("src")).expect("src directory should be created"); + fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n") + .expect("initial source file should be written"); + run_git_test(&repo.path, ["add", "src/app.ts"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]); + + fs::write( + repo.path.join("src/app.ts"), + "export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n", + ) + .expect("updated source file should be written"); + run_git_test(&repo.path, ["add", "src/app.ts"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]); + + let hits = + search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None) + .expect("search should succeed"); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].file, "src/app.ts"); + assert_eq!(hits[0].summary, "add render widget"); + assert_eq!(hits[0].line_number, Some(3)); + } + + #[test] + fn search_code_introductions_finds_multiline_function_block() { + let repo = init_temp_repo("search_multiline_function"); + fs::write(repo.path.join("module.ts"), "export const ready = true;\n") + .expect("initial module should be written"); + run_git_test(&repo.path, ["add", "module.ts"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]); + + let function_body = "export function parseThing() {\n return \"needle-token\";\n}"; + fs::write( + repo.path.join("module.ts"), + format!("export const ready = true;\n\n{function_body}\n"), + ) + .expect("updated module should be written"); + run_git_test(&repo.path, ["add", "module.ts"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]); + + let hits = + search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None) + .expect("search should succeed"); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].file, "module.ts"); + assert_eq!(hits[0].summary, "add parser"); + assert_eq!(hits[0].line_number, Some(3)); + } + + #[test] + fn search_code_introductions_can_be_cancelled() { + let repo = init_temp_repo("search_cancelled"); + fs::write( + repo.path.join("module.ts"), + "export const value = \"needle\";\n", + ) + .expect("module should be written"); + run_git_test(&repo.path, ["add", "module.ts"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]); + + let state = SearchCancellationState::default(); + state + .cancel("test-search") + .expect("cancel flag should be set"); + let result = search_code_introductions_core( + &repo.path, + "needle".to_string(), + false, + 20, + Some(&SearchCancellation { + state: state.clone(), + search_id: "test-search".to_string(), + }), + ); + + assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE); + } + #[test] fn parses_branch_tracking_and_file_states() { let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0"; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 879c1cf..003cdc5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,14 +3,17 @@ mod git; use git::{ - checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status, - list_branches, list_commits, list_file_history, list_repository_files, merge_branch, - open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, - restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files, + cancel_code_search, checkout_branch, commit, compare_commits, diff_file_against_working_tree, + get_status, list_branches, list_commits, list_file_history, list_repository_files, + merge_branch, open_repository, pull, push, read_conflict, resolve_conflict, + resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit, + search_code_introductions, stage_files, unstage_files, SearchCancellationState, }; fn main() { tauri::Builder::default() + .manage(SearchCancellationState::default()) + .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ open_repository, get_status, @@ -30,6 +33,8 @@ fn main() { list_file_history, compare_commits, diff_file_against_working_tree, + search_code_introductions, + cancel_code_search, read_conflict, resolve_conflict, resolve_conflict_side diff --git a/src/App.svelte b/src/App.svelte index 7337991..e981d80 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,7 @@ @@ -566,6 +639,7 @@ onPull={pullRepo} onPush={pushRepo} onRefresh={refreshRepo} + onSearch={() => { globalSearchOpen = true; }} onToggleAutoRefresh={toggleAutoRefresh} /> @@ -583,6 +657,17 @@ placeholder="/path/to/repository" disabled={isBusy} /> + + + + +
+
+ + +
+ + + + + + + {#if isSearching} + + {/if} +
+
+ +
+ {#if !hasRepository} +
Open a repository first.
+ {:else if isSearching} +
+
+ {:else if error} +
{error}
+ {:else if !searched} +
Search a string or paste a complete function to find where it was added.
+ {:else if results.length === 0} +
No introduction found for "{searchedQuery}".
+ {:else} +
+ {results.length} + {results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}" +
+ +
+ {#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)} +
+
+ {hit.short_hash} + {hit.summary || "No commit message"} + {#if hit.matches_added > 1} + +{hit.matches_added} matches + {/if} +
+ +
+ + +
+ +
+
+ +
{hit.line || searchedQuery}
+
+ {/each} +
+ {/if} +
+
+ + diff --git a/src/lib/git.ts b/src/lib/git.ts index d628f3c..2884baa 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -6,6 +6,7 @@ import type { GitCommit, GitCommitComparison, GitRepositoryFile, + GitSearchHit, GitStatus, } from "./types"; @@ -97,6 +98,26 @@ export function diffFileAgainstWorkingTree( return invoke("diff_file_against_working_tree", { path, commit, file }); } +export function searchCodeIntroductions( + path: string, + query: string, + caseSensitive = false, + limit = 250, + searchId?: string, +): Promise { + return invoke("search_code_introductions", { + path, + query, + caseSensitive, + limit, + searchId: searchId ?? null, + }); +} + +export function cancelCodeSearch(searchId: string): Promise { + return invoke("cancel_code_search", { searchId }); +} + export function readConflict(path: string, file: string): Promise { return invoke("read_conflict", { path, file }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 5dcacfa..87fd41a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -71,6 +71,20 @@ export interface GitCommitComparison { patch: string; } +export interface GitSearchHit { + commit_hash: string; + short_hash: string; + summary: string; + author_name: string; + author_email: string; + date: string; + file: string; + old_file: string | null; + line_number: number | null; + line: string; + matches_added: number; +} + export type ExplorerNodeKind = "folder" | "file"; export interface ExplorerNode {