diff --git a/collab-electron/src/main/git-worktree.test.ts b/collab-electron/src/main/git-worktree.test.ts new file mode 100644 index 00000000..29f7c57f --- /dev/null +++ b/collab-electron/src/main/git-worktree.test.ts @@ -0,0 +1,304 @@ +// @ts-ignore -- Bun test types are not loaded by tsconfig.node.json. +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { execFile } from "node:child_process"; +import { + existsSync, + mkdtempSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { + parseBranchList, + parseWorktreeList, + resolveWorktree, + slugifyBranch, + worktreeDirFor, +} from "./git-worktree"; + +const execFileAsync = promisify(execFile); + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trim(); +} + +describe("slugifyBranch", () => { + test("replaces slashes with hyphens", () => { + expect(slugifyBranch("feature/nested/change")).toBe( + "feature-nested-change", + ); + }); + + test("strips unsafe characters and collapses hyphens", () => { + expect(slugifyBranch("feature / a:b?--done")).toBe( + "feature-ab-done", + ); + }); +}); + +describe("worktreeDirFor", () => { + test("is stable for the same repository and branch", () => { + const first = worktreeDirFor("/projects/repo", "feature/x"); + const second = worktreeDirFor("/projects/repo", "feature/x"); + expect(first).toBe(second); + expect(basename(first)).toBe("feature-x"); + }); + + test("separates repositories with the same basename", () => { + const first = worktreeDirFor("/one/repo", "feature/x"); + const second = worktreeDirFor("/two/repo", "feature/x"); + expect(dirname(first)).not.toBe(dirname(second)); + }); +}); + +describe("parseWorktreeList", () => { + test("parses branch worktrees without changing branch names", () => { + const fixture = [ + "worktree /repo", + "HEAD a9ccdf57cbd010300cc9ae8c6077845604b9dabc", + "branch refs/heads/master", + "", + "worktree /wt-feature-x", + "HEAD a9ccdf57cbd010300cc9ae8c6077845604b9dabc", + "branch refs/heads/feature/x", + "", + ].join("\n"); + + expect(parseWorktreeList(fixture)).toEqual([ + { path: "/repo", branch: "master" }, + { path: "/wt-feature-x", branch: "feature/x" }, + ]); + }); + + test("emits null for a detached worktree", () => { + const fixture = [ + "worktree /repo", + "HEAD a9ccdf57cbd010300cc9ae8c6077845604b9dabc", + "detached", + ].join("\n"); + + expect(parseWorktreeList(fixture)).toEqual([ + { path: "/repo", branch: null }, + ]); + }); + + test("returns an empty list for empty input", () => { + expect(parseWorktreeList("")).toEqual([]); + }); +}); + +describe("parseBranchList", () => { + test("parses branch names and the checked-out marker", () => { + const fixture = [ + "feature/x| ", + "master|*", + "other| ", + "", + ].join("\n"); + + expect(parseBranchList(fixture)).toEqual([ + { name: "feature/x", isHead: false }, + { name: "master", isHead: true }, + { name: "other", isHead: false }, + ]); + }); + + test("does not trim the branch name", () => { + expect(parseBranchList(" branch|*\n")).toEqual([ + { name: " branch", isHead: true }, + ]); + }); + + test("returns an empty list for empty input", () => { + expect(parseBranchList("")).toEqual([]); + }); +}); + +describe("resolveWorktree", () => { + let repoRoot = ""; + let headBranch = ""; + const createdPaths = new Set(); + + beforeAll(async () => { + repoRoot = realpathSync( + mkdtempSync(join(tmpdir(), "git-worktree-")), + ); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, [ + "config", + "user.email", + "test@example.com", + ]); + await runGit(repoRoot, ["config", "user.name", "Test"]); + await runGit(repoRoot, ["config", "commit.gpgSign", "false"]); + await runGit(repoRoot, [ + "commit", + "--allow-empty", + "-m", + "init", + ]); + headBranch = await runGit(repoRoot, [ + "symbolic-ref", + "--short", + "HEAD", + ]); + for (const branch of [ + "feature/x", + "other", + "reuse", + "stale", + "existing", + ]) { + await runGit(repoRoot, ["branch", branch]); + } + await runGit(repoRoot, ["checkout", "other"]); + await runGit(repoRoot, [ + "commit", + "--allow-empty", + "-m", + "other", + ]); + await runGit(repoRoot, ["checkout", headBranch]); + }); + + afterAll(async () => { + for (const path of createdPaths) { + rmSync(path, { recursive: true, force: true }); + } + if (repoRoot && existsSync(repoRoot)) { + await runGit(repoRoot, ["worktree", "prune"]); + } + for (const path of createdPaths) { + rmSync(dirname(path), { recursive: true, force: true }); + } + if (repoRoot) { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + test("returns the main tree for its checked-out branch", async () => { + const before = await runGit(repoRoot, [ + "worktree", + "list", + "--porcelain", + ]); + const path = await resolveWorktree(repoRoot, headBranch, { + create: false, + }); + + expect(path).toBe(repoRoot); + expect(await runGit(repoRoot, [ + "worktree", + "list", + "--porcelain", + ])).toBe(before); + }); + + test("creates a worktree for an existing branch", async () => { + const branch = "feature/x"; + const path = await resolveWorktree(repoRoot, branch, { + create: false, + }); + createdPaths.add(path); + + expect(path).toBe(worktreeDirFor(repoRoot, branch)); + expect(existsSync(path)).toBe(true); + expect(await runGit(path, [ + "rev-parse", + "--abbrev-ref", + "HEAD", + ])).toBe(branch); + }); + + test("reuses the same worktree path", async () => { + const first = await resolveWorktree(repoRoot, "reuse", { + create: false, + }); + createdPaths.add(first); + const before = await runGit(repoRoot, [ + "worktree", + "list", + "--porcelain", + ]); + const second = await resolveWorktree(repoRoot, "reuse", { + create: false, + }); + + expect(second).toBe(first); + expect(await runGit(repoRoot, [ + "worktree", + "list", + "--porcelain", + ])).toBe(before); + }); + + test("creates a new branch from an explicit base", async () => { + const branch = "created/from-other"; + const path = await resolveWorktree(repoRoot, branch, { + create: true, + base: "other", + }); + createdPaths.add(path); + + expect(await runGit(path, [ + "rev-parse", + "--abbrev-ref", + "HEAD", + ])).toBe(branch); + expect(await runGit(path, ["rev-parse", "HEAD"])).toBe( + await runGit(repoRoot, ["rev-parse", "other"]), + ); + }); + + test("creates a new branch from HEAD without a base", async () => { + const branch = "created/from-head"; + const path = await resolveWorktree(repoRoot, branch, { + create: true, + }); + createdPaths.add(path); + + expect(await runGit(path, [ + "rev-parse", + "--abbrev-ref", + "HEAD", + ])).toBe(branch); + expect(await runGit(path, ["rev-parse", "HEAD"])).toBe( + await runGit(repoRoot, ["rev-parse", "HEAD"]), + ); + }); + + test("recreates a worktree whose directory was deleted", async () => { + const branch = "stale"; + const first = await resolveWorktree(repoRoot, branch, { + create: false, + }); + createdPaths.add(first); + rmSync(first, { recursive: true, force: true }); + expect(existsSync(first)).toBe(false); + + const second = await resolveWorktree(repoRoot, branch, { + create: false, + }); + + expect(second).toBe(first); + expect(existsSync(second)).toBe(true); + expect(await runGit(second, [ + "rev-parse", + "--abbrev-ref", + "HEAD", + ])).toBe(branch); + }); + + test("rejects creating a branch that already exists", async () => { + const path = worktreeDirFor(repoRoot, "existing"); + createdPaths.add(path); + + await expect(resolveWorktree(repoRoot, "existing", { + create: true, + base: headBranch, + })).rejects.toThrow(); + }); +}); diff --git a/collab-electron/src/main/git-worktree.ts b/collab-electron/src/main/git-worktree.ts new file mode 100644 index 00000000..36cb833e --- /dev/null +++ b/collab-electron/src/main/git-worktree.ts @@ -0,0 +1,156 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { promisify } from "node:util"; + +const WORKTREE_BASE = join(homedir(), ".collaborator", "worktrees"); +const BRANCH_PREFIX = "refs/heads/"; +const execFileAsync = promisify(execFile); + +export interface BranchEntry { + name: string; + isHead: boolean; +} + +export interface WorktreeEntry { + path: string; + branch: string | null; +} + +export function slugifyBranch(branch: string): string { + return branch + .replaceAll("/", "-") + .replace(/[^A-Za-z0-9._-]/g, "") + .replace(/-+/g, "-"); +} + +export function worktreeDirFor( + repoRoot: string, + branch: string, +): string { + const hash = createHash("sha256") + .update(repoRoot) + .digest("hex") + .slice(0, 8); + const repoName = basename(repoRoot); + return join( + WORKTREE_BASE, + `${repoName}-${hash}`, + slugifyBranch(branch), + ); +} + +export function parseWorktreeList( + porcelain: string, +): WorktreeEntry[] { + const entries: WorktreeEntry[] = []; + let path: string | null = null; + let branch: string | null = null; + + const finishEntry = () => { + if (path !== null) entries.push({ path, branch }); + path = null; + branch = null; + }; + + for (const line of porcelain.split(/\r?\n/)) { + if (line === "") { + finishEntry(); + } else if (line.startsWith("worktree ")) { + path = line.slice("worktree ".length); + } else if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length); + branch = ref.startsWith(BRANCH_PREFIX) + ? ref.slice(BRANCH_PREFIX.length) + : ref; + } + } + finishEntry(); + + return entries; +} + +export function parseBranchList(raw: string): BranchEntry[] { + const entries: BranchEntry[] = []; + + for (const line of raw.split(/\r?\n/)) { + if (line.trim() === "") continue; + const separator = line.lastIndexOf("|"); + if (separator === -1) continue; + entries.push({ + name: line.slice(0, separator), + isHead: line.slice(separator + 1) === "*", + }); + } + + return entries; +} + +async function git(args: string[], repoRoot: string): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd: repoRoot, + maxBuffer: 50 * 1024 * 1024, + }); + return stdout; +} + +export async function findRepoRoot(cwd: string): Promise { + try { + return (await git(["rev-parse", "--show-toplevel"], cwd)).trim(); + } catch { + return null; + } +} + +export async function listBranches( + repoRoot: string, +): Promise { + const stdout = await git( + [ + "for-each-ref", + "--format=%(refname:short)|%(HEAD)", + "refs/heads", + ], + repoRoot, + ); + return parseBranchList(stdout); +} + +export async function listWorktrees( + repoRoot: string, +): Promise { + const stdout = await git( + ["worktree", "list", "--porcelain"], + repoRoot, + ); + return parseWorktreeList(stdout); +} + +export async function resolveWorktree( + repoRoot: string, + branch: string, + opts: { create: boolean; base?: string | undefined }, +): Promise { + const existing = (await listWorktrees(repoRoot)).find( + (entry) => entry.branch === branch, + ); + if (existing && existsSync(existing.path)) return existing.path; + if (existing) { + // Git still lists a worktree whose directory was removed by hand. + // Prune the stale record so the add below can succeed. + await git(["worktree", "prune"], repoRoot); + } + + const path = worktreeDirFor(repoRoot, branch); + if (opts.create) { + const args = ["worktree", "add", "-b", branch, path]; + if (opts.base !== undefined) args.push(opts.base); + await git(args, repoRoot); + } else { + await git(["worktree", "add", path, branch], repoRoot); + } + + return path; +} diff --git a/collab-electron/src/main/ipc-misc.ts b/collab-electron/src/main/ipc-misc.ts index f6579869..cb2bcd09 100644 --- a/collab-electron/src/main/ipc-misc.ts +++ b/collab-electron/src/main/ipc-misc.ts @@ -7,6 +7,11 @@ import { type BrowserWindow, } from "electron"; import * as gitReplay from "./git-replay"; +import { + findRepoRoot, + listBranches, + resolveWorktree, +} from "./git-worktree"; import { importWebArticle } from "./import-service"; import * as agentActivity from "./agent-activity"; import { registerMethod } from "./json-rpc-server"; @@ -132,6 +137,46 @@ export function registerMiscHandlers( }, ); + ipcMain.handle( + "git:list-branches", + async (_event, workspacePath: string) => { + try { + const repoRoot = await findRepoRoot(workspacePath); + if (!repoRoot) return null; + return { + repoRoot, + branches: await listBranches(repoRoot), + }; + } catch { + return null; + } + }, + ); + + ipcMain.handle( + "git:resolve-worktree", + async ( + _event, + repoRoot: string, + branch: string, + create: boolean, + base?: string, + ) => { + try { + return { + cwd: await resolveWorktree(repoRoot, branch, { + create, + base, + }), + }; + } catch (err) { + return { + error: err instanceof Error ? err.message : String(err), + }; + } + }, + ); + // Open external URL ipcMain.on( "shell:open-external", diff --git a/collab-electron/src/preload/shell.ts b/collab-electron/src/preload/shell.ts index 6eda2223..a1696e79 100644 --- a/collab-electron/src/preload/shell.ts +++ b/collab-electron/src/preload/shell.ts @@ -223,6 +223,23 @@ contextBridge.exposeInMainWorld("shellApi", { items: Array<{ id: string; label: string; enabled?: boolean }>, ) => ipcRenderer.invoke("context-menu:show", items), + gitListBranches: (workspacePath: string) => + ipcRenderer.invoke("git:list-branches", workspacePath), + + gitResolveWorktree: ( + repoRoot: string, + branch: string, + create: boolean, + base?: string, + ) => + ipcRenderer.invoke( + "git:resolve-worktree", + repoRoot, + branch, + create, + base, + ), + openExternal: (url: string) => ipcRenderer.send("shell:open-external", url), trackEvent: (name: string, properties?: Record) => { diff --git a/collab-electron/src/windows/shell/src/prompt-modal.js b/collab-electron/src/windows/shell/src/prompt-modal.js new file mode 100644 index 00000000..a79243ba --- /dev/null +++ b/collab-electron/src/windows/shell/src/prompt-modal.js @@ -0,0 +1,96 @@ +export function promptForText({ + title, + label, + placeholder, + initialValue, +}) { + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.id = "prompt-backdrop"; + + const card = document.createElement("div"); + card.id = "prompt-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-labelledby", "prompt-title"); + + const heading = document.createElement("h2"); + heading.id = "prompt-title"; + heading.textContent = title; + + const labelEl = document.createElement("label"); + labelEl.htmlFor = "prompt-input"; + labelEl.textContent = label; + + const input = document.createElement("input"); + input.id = "prompt-input"; + input.type = "text"; + input.placeholder = placeholder ?? ""; + input.value = initialValue ?? ""; + + const actions = document.createElement("div"); + actions.id = "prompt-actions"; + + const cancelButton = document.createElement("button"); + cancelButton.id = "prompt-cancel"; + cancelButton.type = "button"; + cancelButton.textContent = "Cancel"; + + const okButton = document.createElement("button"); + okButton.id = "prompt-ok"; + okButton.type = "button"; + okButton.textContent = "OK"; + + actions.append(cancelButton, okButton); + card.append(heading, labelEl, input, actions); + backdrop.appendChild(card); + document.body.appendChild(backdrop); + + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + document.removeEventListener("keydown", onKeydown, true); + backdrop.remove(); + resolve(value); + }; + const commit = () => finish(input.value.trim()); + const cancel = () => finish(null); + const onKeydown = (e) => { + if (e.key === "Escape" && e.target !== input) { + e.preventDefault(); + e.stopPropagation(); + cancel(); + return; + } + if ( + e.target !== input && + (e.key === "Backspace" || e.key === "Delete") + ) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } + if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + e.stopPropagation(); + }); + cancelButton.addEventListener("click", cancel); + okButton.addEventListener("click", commit); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) cancel(); + }); + document.addEventListener("keydown", onKeydown, true); + + input.focus(); + input.select(); + }); +} diff --git a/collab-electron/src/windows/shell/src/renderer.js b/collab-electron/src/windows/shell/src/renderer.js index 75055eec..54b2269e 100644 --- a/collab-electron/src/windows/shell/src/renderer.js +++ b/collab-electron/src/windows/shell/src/renderer.js @@ -17,6 +17,7 @@ import { createTileManager } from "./tile-manager.js"; import { resolveTileNavigation } from "./tile-navigation.js"; import { updateTileTitle, getTileLabel } from "./tile-renderer.js"; import { initOutreachModal } from "./outreach-modal.js"; +import { promptForText } from "./prompt-modal.js"; const CANVAS_DBLCLICK_SUPPRESS_MS = 500; const IS_WINDOWS = window.shellApi.getPlatform() === "win32"; @@ -652,6 +653,120 @@ async function init() { }); minimapRef = minimap; + async function createBranchTerminal(cx, cy) { + const root = workspaceData.workspaces[0]; + if (!root) return; + + const openPlain = () => openTerminalAt(root, cx, cy); + + const info = await window.shellApi.gitListBranches(root); + if (!info) return openPlain(); + + const branchItems = info.branches.map((branch) => ({ + id: `branch:${branch.name}`, + label: branch.isHead + ? `${branch.name} (current)` + : branch.name, + })); + const items = [ + { id: "new-branch", label: "Create new branch…" }, + { id: "separator", label: "" }, + ...branchItems, + ]; + const picked = await window.shellApi.showContextMenu(items); + + if (picked === "new-branch") { + const name = await promptForText({ + title: "New branch", + label: "Branch name", + placeholder: "feature/my-change", + }); + if (name === null) return openPlain(); + + let invalidReason = null; + if (name === "") { + invalidReason = "Branch name cannot be empty."; + } else if (name.startsWith("-")) { + invalidReason = "Branch name cannot start with '-'."; + } else if (name.includes("..")) { + invalidReason = "Branch name cannot contain '..'."; + } else if ( + [...name].some((character) => + /\s/.test(character) || + "~^:?*[\\".includes(character) + ) + ) { + invalidReason = + "Branch name cannot contain whitespace, " + + "~, ^, :, ?, *, [, or backslash."; + } + if (invalidReason) { + await window.shellApi.showConfirmDialog({ + message: "Invalid branch name", + detail: invalidReason, + buttons: ["OK"], + }); + return; + } + + const basePicked = await window.shellApi.showContextMenu( + branchItems, + ); + const base = basePicked?.startsWith("branch:") + ? basePicked.slice("branch:".length) + : undefined; + const result = await window.shellApi.gitResolveWorktree( + info.repoRoot, + name, + true, + base, + ); + if (result.error) { + await window.shellApi.showConfirmDialog({ + message: "Could not open branch worktree", + detail: result.error, + buttons: ["OK"], + }); + return; + } + + openTerminalAt(result.cwd, cx, cy); + return; + } + + if (!picked || !picked.startsWith("branch:")) { + return openPlain(); + } + + const branch = picked.slice("branch:".length); + const result = await window.shellApi.gitResolveWorktree( + info.repoRoot, + branch, + false, + ); + if (result.error) { + await window.shellApi.showConfirmDialog({ + message: "Could not open branch worktree", + detail: result.error, + buttons: ["OK"], + }); + return; + } + + openTerminalAt(result.cwd, cx, cy); + } + + /** The existing terminal-tile creation path, parameterised by cwd. */ + function openTerminalAt(cwd, cx, cy) { + const size = getTerminalSize(); + const tile = tileManager.createCanvasTile( + "term", cx, cy, { cwd, ...size }, + ); + tileManager.spawnTerminal(tile, true); + tileManager.saveCanvasImmediate(); + minimap.update(); + } + // -- Canvas RPC -- const handleCanvasRpc = createCanvasRpc({ @@ -873,18 +988,17 @@ async function init() { const selected = await window.shellApi.showContextMenu([ { id: "new-terminal", label: "New terminal tile" }, + { + id: "new-terminal-branch", + label: "New terminal on branch…", + }, { id: "new-browser", label: "New browser tile" }, ]); if (selected === "new-terminal") { - const cwd = getTerminalCwd(); - const size = getTerminalSize(); - const tile = tileManager.createCanvasTile( - "term", cx, cy, { cwd, ...size }, - ); - tileManager.spawnTerminal(tile, true); - tileManager.saveCanvasImmediate(); - minimap.update(); + openTerminalAt(getTerminalCwd(), cx, cy); + } else if (selected === "new-terminal-branch") { + await createBranchTerminal(cx, cy); } else if (selected === "new-browser") { const tile = tileManager.createCanvasTile( "browser", cx, cy, @@ -1505,8 +1619,24 @@ async function init() { newTileBtn.addEventListener("click", async () => { const selected = await window.shellApi.showContextMenu([ { id: "new-terminal", label: "New terminal tile" }, + { + id: "new-terminal-branch", + label: "New terminal on branch…", + }, { id: "new-browser", label: "New browser tile" }, ]); + if (selected === "new-terminal-branch") { + const rect = panelViewer.getBoundingClientRect(); + const size = getTerminalSize(); + const cx = ( + rect.width / 2 - viewportState.panX + ) / viewportState.zoom - size.width / 2; + const cy = ( + rect.height / 2 - viewportState.panY + ) / viewportState.zoom - size.height / 2; + await createBranchTerminal(cx, cy); + return; + } const type = selected === "new-terminal" ? "term" : selected === "new-browser" ? "browser" : null; diff --git a/collab-electron/src/windows/shell/src/shell.css b/collab-electron/src/windows/shell/src/shell.css index 44d5feec..a2586db0 100644 --- a/collab-electron/src/windows/shell/src/shell.css +++ b/collab-electron/src/windows/shell/src/shell.css @@ -1369,3 +1369,93 @@ body.platform-win { #outreach-snooze:hover { text-decoration: underline; } + +/* -- Text prompt modal -- */ + +#prompt-backdrop { + position: fixed; + inset: 0; + z-index: 1000000; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; +} + +#prompt-card { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 28px 32px; + width: min(400px, calc(100vw - 64px)); + font-family: var(--font-sans); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); +} + +#prompt-card h2 { + margin: 0 0 20px; + font-size: 18px; +} + +#prompt-card label { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +#prompt-input { + font: inherit; + font-size: 12px; + background: transparent; + color: inherit; + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 3px; + padding: 8px 10px; + outline: none; + width: 100%; + box-sizing: border-box; +} + +#prompt-input:focus { + border-color: rgba(0, 0, 0, 0.5); +} + +.dark #prompt-input:focus { + border-color: rgba(255, 255, 255, 0.5); +} + +#prompt-input::placeholder { + color: rgba(128, 128, 128, 0.5); +} + +#prompt-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +#prompt-actions button { + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--fg); + font-size: 13px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; +} + +#prompt-actions #prompt-ok { + border-color: transparent; + background: var(--edge-dot); + color: white; +} + +#prompt-actions #prompt-ok:hover { + background: var(--edge-dot-hover); +}