From b647a38c2b1425624dc2506f8b09c62e7276712d Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:13 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(config):=20Appearance/Editor/Keybind?= =?UTF-8?q?ings/Tools=E8=A8=AD=E5=AE=9A=E3=83=A2=E3=83=87=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppConfigにAppearanceConfig(テーマ・カラー・フォントサイズ・レイアウト)、 EditorConfig(フォント・表示・インデント設定)、KeybindingsConfig(プリセット)、 ToolsConfig(外部ツール・自動フェッチ)を追加。 全フィールドに#[serde(default)]を付与し既存config.tomlとの後方互換性を確保。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/config/mod.rs | 218 ++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 4dec1c5..0a3598e 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -11,6 +11,104 @@ pub struct AppConfig { pub last_opened_repo: Option, #[serde(default)] pub ai: AiConfig, + #[serde(default)] + pub appearance: AppearanceConfig, + #[serde(default)] + pub editor: EditorConfig, + #[serde(default)] + pub keybindings: KeybindingsConfig, + #[serde(default)] + pub tools: ToolsConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppearanceConfig { + pub theme: String, + pub color_theme: String, + pub ui_font_size: u32, + pub sidebar_position: String, + pub tab_style: String, +} + +impl Default for AppearanceConfig { + fn default() -> Self { + Self { + theme: "dark".to_string(), + color_theme: "cobalt".to_string(), + ui_font_size: 13, + sidebar_position: "left".to_string(), + tab_style: "default".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditorConfig { + pub font_family: String, + pub font_size: u32, + pub line_height: u32, + pub show_line_numbers: bool, + pub word_wrap: bool, + pub show_whitespace: bool, + pub minimap: bool, + pub indent_style: String, + pub tab_size: u32, +} + +impl Default for EditorConfig { + fn default() -> Self { + Self { + font_family: "JetBrains Mono".to_string(), + font_size: 14, + line_height: 20, + show_line_numbers: true, + word_wrap: false, + show_whitespace: false, + minimap: true, + indent_style: "spaces".to_string(), + tab_size: 2, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeybindingsConfig { + pub preset: String, +} + +impl Default for KeybindingsConfig { + fn default() -> Self { + Self { + preset: "default".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolsConfig { + pub diff_tool: String, + pub merge_tool: String, + pub terminal: String, + pub editor: String, + pub git_path: String, + pub auto_fetch_on_open: bool, + pub auto_fetch_interval: u32, + pub open_in_editor_on_double_click: bool, +} + +impl Default for ToolsConfig { + fn default() -> Self { + Self { + diff_tool: "builtin".to_string(), + merge_tool: "builtin".to_string(), + terminal: "default".to_string(), + editor: "vscode".to_string(), + git_path: "/usr/bin/git".to_string(), + auto_fetch_on_open: true, + auto_fetch_interval: 300, + open_in_editor_on_double_click: true, + } + } } fn config_path() -> GitResult { @@ -55,6 +153,10 @@ mod tests { let config = AppConfig { last_opened_repo: Some("/tmp/test-repo".to_string()), ai: AiConfig::default(), + appearance: AppearanceConfig::default(), + editor: EditorConfig::default(), + keybindings: KeybindingsConfig::default(), + tools: ToolsConfig::default(), }; let serialized = toml::to_string(&config).unwrap(); let deserialized: AppConfig = toml::from_str(&serialized).unwrap(); @@ -69,4 +171,120 @@ mod tests { let config: AppConfig = toml::from_str("").unwrap(); assert!(config.last_opened_repo.is_none()); } + + #[test] + fn appearance_config_defaults() { + let config = AppearanceConfig::default(); + assert_eq!(config.theme, "dark"); + assert_eq!(config.color_theme, "cobalt"); + assert_eq!(config.ui_font_size, 13); + assert_eq!(config.sidebar_position, "left"); + assert_eq!(config.tab_style, "default"); + } + + #[test] + fn editor_config_defaults() { + let config = EditorConfig::default(); + assert_eq!(config.font_family, "JetBrains Mono"); + assert_eq!(config.font_size, 14); + assert_eq!(config.line_height, 20); + assert!(config.show_line_numbers); + assert!(!config.word_wrap); + assert!(!config.show_whitespace); + assert!(config.minimap); + assert_eq!(config.indent_style, "spaces"); + assert_eq!(config.tab_size, 2); + } + + #[test] + fn keybindings_config_defaults() { + let config = KeybindingsConfig::default(); + assert_eq!(config.preset, "default"); + } + + #[test] + fn tools_config_defaults() { + let config = ToolsConfig::default(); + assert_eq!(config.diff_tool, "builtin"); + assert_eq!(config.merge_tool, "builtin"); + assert_eq!(config.terminal, "default"); + assert_eq!(config.editor, "vscode"); + assert_eq!(config.git_path, "/usr/bin/git"); + assert!(config.auto_fetch_on_open); + assert_eq!(config.auto_fetch_interval, 300); + assert!(config.open_in_editor_on_double_click); + } + + #[test] + fn config_deserializes_partial_toml_with_new_sections() { + let toml_str = r#" +last_opened_repo = "/tmp/repo" + +[appearance] +theme = "light" +color_theme = "emerald" +ui_font_size = 15 +sidebar_position = "right" +tab_style = "compact" +"#; + let config: AppConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.last_opened_repo, Some("/tmp/repo".to_string())); + assert_eq!(config.appearance.theme, "light"); + assert_eq!(config.appearance.color_theme, "emerald"); + assert_eq!(config.appearance.ui_font_size, 15); + assert_eq!(config.appearance.sidebar_position, "right"); + assert_eq!(config.appearance.tab_style, "compact"); + // Other sections should use defaults + assert_eq!(config.editor.font_size, 14); + assert_eq!(config.keybindings.preset, "default"); + assert_eq!(config.tools.diff_tool, "builtin"); + } + + #[test] + fn config_full_roundtrip_with_all_sections() { + let config = AppConfig { + last_opened_repo: Some("/tmp/test".to_string()), + ai: AiConfig::default(), + appearance: AppearanceConfig { + theme: "light".to_string(), + color_theme: "rose".to_string(), + ui_font_size: 16, + sidebar_position: "right".to_string(), + tab_style: "compact".to_string(), + }, + editor: EditorConfig { + font_family: "Fira Code".to_string(), + font_size: 16, + line_height: 24, + show_line_numbers: false, + word_wrap: true, + show_whitespace: true, + minimap: false, + indent_style: "tabs".to_string(), + tab_size: 4, + }, + keybindings: KeybindingsConfig { + preset: "vim".to_string(), + }, + tools: ToolsConfig { + diff_tool: "vscode".to_string(), + merge_tool: "meld".to_string(), + terminal: "iterm".to_string(), + editor: "cursor".to_string(), + git_path: "/opt/homebrew/bin/git".to_string(), + auto_fetch_on_open: false, + auto_fetch_interval: 600, + open_in_editor_on_double_click: false, + }, + }; + let serialized = toml::to_string(&config).unwrap(); + let deserialized: AppConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(deserialized.appearance.theme, "light"); + assert_eq!(deserialized.appearance.color_theme, "rose"); + assert_eq!(deserialized.editor.font_family, "Fira Code"); + assert_eq!(deserialized.editor.tab_size, 4); + assert_eq!(deserialized.keybindings.preset, "vim"); + assert_eq!(deserialized.tools.diff_tool, "vscode"); + assert!(!deserialized.tools.auto_fetch_on_open); + } } From 620aa155ae672a9474570f332dd0af5db620094a Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:19 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(theme):=20=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=88/=E3=83=80=E3=83=BC=E3=82=AF=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=83=BB=E3=82=AB=E3=83=A9=E3=83=BC=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS変数ベースのテーマ切り替えを実装。data-theme属性でライト/ダーク、 data-color-theme属性でCobalt/Emerald/Rose/Amber/Slate/Violetの6カラーを切り替え。 configStoreで設定状態管理、useThemeフックでDOM属性を即座に反映。 Co-Authored-By: Claude Opus 4.6 --- src/hooks/useTheme.ts | 14 +++++++++ src/services/config.ts | 61 +++++++++++++++++++++++++++++++++++++++ src/stores/configStore.ts | 57 ++++++++++++++++++++++++++++++++++++ src/styles/variables.css | 54 +++++++++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useTheme.ts create mode 100644 src/services/config.ts create mode 100644 src/stores/configStore.ts diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..196f169 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import { useConfigStore } from "../stores/configStore"; + +export function useTheme() { + const appearance = useConfigStore((s) => s.config?.appearance); + + useEffect(() => { + if (!appearance) return; + + const root = document.documentElement; + root.setAttribute("data-theme", appearance.theme); + root.setAttribute("data-color-theme", appearance.color_theme); + }, [appearance]); +} diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..4b6a34e --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,61 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface AppearanceConfig { + theme: string; + color_theme: string; + ui_font_size: number; + sidebar_position: string; + tab_style: string; +} + +export interface EditorConfig { + font_family: string; + font_size: number; + line_height: number; + show_line_numbers: boolean; + word_wrap: boolean; + show_whitespace: boolean; + minimap: boolean; + indent_style: string; + tab_size: number; +} + +export interface KeybindingsConfig { + preset: string; +} + +export interface ToolsConfig { + diff_tool: string; + merge_tool: string; + terminal: string; + editor: string; + git_path: string; + auto_fetch_on_open: boolean; + auto_fetch_interval: number; + open_in_editor_on_double_click: boolean; +} + +export interface AiConfig { + commit_message_style: string; + commit_message_language: string; + provider_priority: string[]; + prefer_local_llm: boolean; + exclude_patterns: string[]; +} + +export interface AppConfig { + last_opened_repo: string | null; + ai: AiConfig; + appearance: AppearanceConfig; + editor: EditorConfig; + keybindings: KeybindingsConfig; + tools: ToolsConfig; +} + +export function getConfig(): Promise { + return invoke("get_config"); +} + +export function saveConfig(config: AppConfig): Promise { + return invoke("save_config", { config }); +} diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts new file mode 100644 index 0000000..6b131c7 --- /dev/null +++ b/src/stores/configStore.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import type { AppConfig, AppearanceConfig } from "../services/config"; +import { getConfig, saveConfig as saveConfigService } from "../services/config"; + +interface ConfigState { + config: AppConfig | null; + loading: boolean; + error: string | null; +} + +interface ConfigActions { + loadConfig: () => Promise; + saveConfig: (config: AppConfig) => Promise; + updateAppearance: (appearance: AppearanceConfig) => Promise; +} + +export const useConfigStore = create( + (set, get) => ({ + config: null, + loading: false, + error: null, + + loadConfig: async () => { + set({ loading: true, error: null }); + try { + const config = await getConfig(); + set({ config, loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + saveConfig: async (config: AppConfig) => { + try { + await saveConfigService(config); + set({ config }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + updateAppearance: async (appearance: AppearanceConfig) => { + const current = get().config; + if (!current) return; + const updated = { ...current, appearance }; + try { + await saveConfigService(updated); + set({ config: updated }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + }), +); diff --git a/src/styles/variables.css b/src/styles/variables.css index b6b74ed..f89fe0b 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,4 +1,4 @@ -/* ===== CSS Variables ===== */ +/* ===== CSS Variables (Dark mode = default) ===== */ :root { --bg-primary: #0f1419; --bg-secondary: #151b22; @@ -24,6 +24,58 @@ --diff-del-line: rgba(248, 81, 73, 0.4); } +/* ===== Light Mode ===== */ +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --bg-tertiary: #eaeef2; + --bg-hover: #d0d7de; + --text-primary: #1f2328; + --text-secondary: #656d76; + --text-muted: #8b949e; + --border: #d0d7de; + --accent: #0969da; + --accent-dim: rgba(9, 105, 218, 0.1); + --success: #1a7f37; + --success-dim: rgba(26, 127, 55, 0.1); + --warning: #9a6700; + --warning-dim: rgba(154, 103, 0, 0.1); + --danger: #cf222e; + --danger-dim: rgba(207, 34, 46, 0.1); + --purple: #8250df; + --purple-dim: rgba(130, 80, 223, 0.1); + --diff-add-bg: rgba(26, 127, 55, 0.1); + --diff-add-line: rgba(26, 127, 55, 0.3); + --diff-del-bg: rgba(207, 34, 46, 0.1); + --diff-del-line: rgba(207, 34, 46, 0.3); +} + +/* ===== Color Themes ===== */ +[data-color-theme="emerald"] { + --accent: #34d399; + --accent-dim: rgba(52, 211, 153, 0.15); +} + +[data-color-theme="rose"] { + --accent: #f472b6; + --accent-dim: rgba(244, 114, 182, 0.15); +} + +[data-color-theme="amber"] { + --accent: #f59e0b; + --accent-dim: rgba(245, 158, 11, 0.15); +} + +[data-color-theme="slate"] { + --accent: #a5a5b4; + --accent-dim: rgba(165, 165, 180, 0.15); +} + +[data-color-theme="violet"] { + --accent: #a78bfa; + --accent-dim: rgba(167, 139, 250, 0.15); +} + /* ===== Reset & Base ===== */ * { margin: 0; From 61a399a0175e7bb3e9919939d345f2baa01aee09 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:26 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(settings):=20Settings=20Modal?= =?UTF-8?q?=E3=82=925=E3=82=BF=E3=83=96=E6=A7=8B=E6=88=90=E3=81=AB?= =?UTF-8?q?=E6=8B=A1=E5=BC=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI Settingsのみから、Appearance/Editor/Keybindings/External Tools/AI Settingsの 5タブ構成に拡張。各タブコンポーネントを新規作成し、デザインモックに準拠した UIを実装。テーマセレクタ・カラーピッカー・キーバインドリストのCSSを追加。 Co-Authored-By: Claude Opus 4.6 --- .../organisms/SettingsAppearanceTab.tsx | 180 ++++++++++++++++ .../organisms/SettingsEditorTab.tsx | 173 +++++++++++++++ .../organisms/SettingsKeybindingsTab.tsx | 103 +++++++++ src/components/organisms/SettingsModal.tsx | 34 ++- src/components/organisms/SettingsToolsTab.tsx | 200 ++++++++++++++++++ src/styles/settings.css | 115 ++++++++++ 6 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 src/components/organisms/SettingsAppearanceTab.tsx create mode 100644 src/components/organisms/SettingsEditorTab.tsx create mode 100644 src/components/organisms/SettingsKeybindingsTab.tsx create mode 100644 src/components/organisms/SettingsToolsTab.tsx diff --git a/src/components/organisms/SettingsAppearanceTab.tsx b/src/components/organisms/SettingsAppearanceTab.tsx new file mode 100644 index 0000000..db52449 --- /dev/null +++ b/src/components/organisms/SettingsAppearanceTab.tsx @@ -0,0 +1,180 @@ +import { type ChangeEvent, useCallback, useEffect } from "react"; +import { useConfigStore } from "../../stores/configStore"; +import { useUIStore } from "../../stores/uiStore"; + +const THEMES = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +] as const; + +const COLOR_THEMES = [ + { value: "cobalt", color: "#58a6ff" }, + { value: "emerald", color: "#34d399" }, + { value: "rose", color: "#f472b6" }, + { value: "amber", color: "#f59e0b" }, + { value: "slate", color: "#a5a5b4" }, + { value: "violet", color: "#a78bfa" }, +] as const; + +export function SettingsAppearanceTab() { + const config = useConfigStore((s) => s.config); + const loadConfig = useConfigStore((s) => s.loadConfig); + const updateAppearance = useConfigStore((s) => s.updateAppearance); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + loadConfig().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [loadConfig, addToast]); + + const appearance = config?.appearance; + + const handleThemeChange = useCallback( + (theme: string) => { + if (!appearance) return; + updateAppearance({ ...appearance, theme }).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [appearance, updateAppearance, addToast], + ); + + const handleColorThemeChange = useCallback( + (colorTheme: string) => { + if (!appearance) return; + updateAppearance({ ...appearance, color_theme: colorTheme }).catch( + (e: unknown) => { + addToast(String(e), "error"); + }, + ); + }, + [appearance, updateAppearance, addToast], + ); + + const handleFontSizeChange = useCallback( + (e: ChangeEvent) => { + if (!appearance) return; + updateAppearance({ + ...appearance, + ui_font_size: Number(e.target.value), + }).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [appearance, updateAppearance, addToast], + ); + + const handleSidebarPositionChange = useCallback( + (e: ChangeEvent) => { + if (!appearance) return; + updateAppearance({ + ...appearance, + sidebar_position: e.target.value, + }).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [appearance, updateAppearance, addToast], + ); + + const handleTabStyleChange = useCallback( + (e: ChangeEvent) => { + if (!appearance) return; + updateAppearance({ ...appearance, tab_style: e.target.value }).catch( + (e: unknown) => { + addToast(String(e), "error"); + }, + ); + }, + [appearance, updateAppearance, addToast], + ); + + if (!appearance) return null; + + return ( +
+
+

Theme

+
+ {THEMES.map((t) => ( + + ))} +
+
+ +
+

Color Theme

+
+ {COLOR_THEMES.map((ct) => ( +
+
+ +
+

Font Size

+
+ + + {appearance.ui_font_size}px +
+
+ +
+

Layout

+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/components/organisms/SettingsEditorTab.tsx b/src/components/organisms/SettingsEditorTab.tsx new file mode 100644 index 0000000..6b10a00 --- /dev/null +++ b/src/components/organisms/SettingsEditorTab.tsx @@ -0,0 +1,173 @@ +import { type ChangeEvent, useCallback, useEffect } from "react"; +import type { AppConfig, EditorConfig } from "../../services/config"; +import { useConfigStore } from "../../stores/configStore"; +import { useUIStore } from "../../stores/uiStore"; + +export function SettingsEditorTab() { + const config = useConfigStore((s) => s.config); + const loadConfig = useConfigStore((s) => s.loadConfig); + const saveConfig = useConfigStore((s) => s.saveConfig); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + loadConfig().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [loadConfig, addToast]); + + const editor = config?.editor; + + const updateEditor = useCallback( + (partial: Partial) => { + if (!config || !editor) return; + const updated: AppConfig = { + ...config, + editor: { ...editor, ...partial }, + }; + saveConfig(updated).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [config, editor, saveConfig, addToast], + ); + + if (!editor) return null; + + return ( +
+
+

Font

+
+ + +
+
+ + ) => + updateEditor({ font_size: Number(e.target.value) }) + } + /> + {editor.font_size}px +
+
+ + ) => + updateEditor({ line_height: Number(e.target.value) }) + } + /> + {editor.line_height}px +
+
+ +
+

Display

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Indentation

+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/components/organisms/SettingsKeybindingsTab.tsx b/src/components/organisms/SettingsKeybindingsTab.tsx new file mode 100644 index 0000000..5589e3e --- /dev/null +++ b/src/components/organisms/SettingsKeybindingsTab.tsx @@ -0,0 +1,103 @@ +import { type ChangeEvent, useCallback, useEffect } from "react"; +import type { AppConfig } from "../../services/config"; +import { useConfigStore } from "../../stores/configStore"; +import { useUIStore } from "../../stores/uiStore"; + +const KEYBINDING_SECTIONS = [ + { + title: "General", + bindings: [ + { action: "Open Settings", key: "\u2318 ," }, + { action: "Search", key: "\u2318 F" }, + { action: "Toggle Sidebar", key: "\u2318 B" }, + { action: "New Tab", key: "\u2318 T" }, + { action: "Close Tab", key: "\u2318 W" }, + ], + }, + { + title: "Git Operations", + bindings: [ + { action: "Commit", key: "\u2318 Enter" }, + { action: "Push", key: "\u2318 \u21e7 P" }, + { action: "Pull", key: "\u2318 \u21e7 L" }, + { action: "Fetch", key: "\u2318 \u21e7 F" }, + { action: "Stage All", key: "\u2318 \u21e7 A" }, + { action: "Unstage All", key: "\u2318 \u21e7 U" }, + ], + }, + { + title: "Navigation", + bindings: [ + { action: "Changes", key: "\u2318 1" }, + { action: "History", key: "\u2318 2" }, + { action: "Branches", key: "\u2318 3" }, + { action: "Stash", key: "\u2318 4" }, + ], + }, +] as const; + +export function SettingsKeybindingsTab() { + const config = useConfigStore((s) => s.config); + const loadConfig = useConfigStore((s) => s.loadConfig); + const saveConfig = useConfigStore((s) => s.saveConfig); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + loadConfig().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [loadConfig, addToast]); + + const keybindings = config?.keybindings; + + const handlePresetChange = useCallback( + (e: ChangeEvent) => { + if (!config) return; + const updated: AppConfig = { + ...config, + keybindings: { ...config.keybindings, preset: e.target.value }, + }; + saveConfig(updated).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [config, saveConfig, addToast], + ); + + if (!keybindings) return null; + + return ( +
+
+

Preset

+
+ + +
+
+ + {KEYBINDING_SECTIONS.map((section) => ( +
+

{section.title}

+
+ {section.bindings.map((binding) => ( +
+ {binding.action} + {binding.key} +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/organisms/SettingsModal.tsx b/src/components/organisms/SettingsModal.tsx index da95667..eddd3a6 100644 --- a/src/components/organisms/SettingsModal.tsx +++ b/src/components/organisms/SettingsModal.tsx @@ -1,19 +1,49 @@ +import { useState } from "react"; import { Modal } from "./Modal"; import { SettingsAiTab } from "./SettingsAiTab"; +import { SettingsAppearanceTab } from "./SettingsAppearanceTab"; +import { SettingsEditorTab } from "./SettingsEditorTab"; +import { SettingsKeybindingsTab } from "./SettingsKeybindingsTab"; +import { SettingsToolsTab } from "./SettingsToolsTab"; + +type SettingsTabId = "appearance" | "editor" | "keybindings" | "tools" | "ai"; + +const TABS: { id: SettingsTabId; label: string }[] = [ + { id: "appearance", label: "Appearance" }, + { id: "editor", label: "Editor" }, + { id: "keybindings", label: "Keybindings" }, + { id: "tools", label: "External Tools" }, + { id: "ai", label: "AI Settings" }, +]; interface SettingsModalProps { onClose: () => void; } export function SettingsModal({ onClose }: SettingsModalProps) { + const [activeTab, setActiveTab] = useState("appearance"); + return (
- + {activeTab === "appearance" && } + {activeTab === "editor" && } + {activeTab === "keybindings" && } + {activeTab === "tools" && } + {activeTab === "ai" && }
diff --git a/src/components/organisms/SettingsToolsTab.tsx b/src/components/organisms/SettingsToolsTab.tsx new file mode 100644 index 0000000..ede849f --- /dev/null +++ b/src/components/organisms/SettingsToolsTab.tsx @@ -0,0 +1,200 @@ +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; +import type { AppConfig, ToolsConfig } from "../../services/config"; +import { useConfigStore } from "../../stores/configStore"; +import { useUIStore } from "../../stores/uiStore"; + +export function SettingsToolsTab() { + const config = useConfigStore((s) => s.config); + const loadConfig = useConfigStore((s) => s.loadConfig); + const saveConfig = useConfigStore((s) => s.saveConfig); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + loadConfig().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [loadConfig, addToast]); + + const tools = config?.tools; + + const updateTools = useCallback( + (partial: Partial) => { + if (!config || !tools) return; + const updated: AppConfig = { + ...config, + tools: { ...tools, ...partial }, + }; + saveConfig(updated).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, + [config, tools, saveConfig, addToast], + ); + + const [gitPathLocal, setGitPathLocal] = useState(""); + + useEffect(() => { + if (tools) { + setGitPathLocal(tools.git_path); + } + }, [tools]); + + if (!tools) return null; + + return ( +
+
+

Diff Tool

+
+ + +
+
+ +
+

Merge Tool

+
+ + +
+
+ +
+

Terminal

+
+ + +
+
+ +
+

Editor

+
+ + +
+
+ + +
+
+ +
+

Git

+
+ +
+ ) => + setGitPathLocal(e.target.value) + } + onBlur={() => updateTools({ git_path: gitPathLocal })} + placeholder="Path to git binary" + /> +
+
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/src/styles/settings.css b/src/styles/settings.css index fc4ed9f..843b309 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -290,3 +290,118 @@ .settings-input::placeholder { color: var(--text-muted); } + +/* ===== Theme Selector ===== */ +.theme-selector { + display: flex; + gap: 12px; +} + +.theme-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--bg-tertiary); + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s ease; +} + +.theme-option:hover { + border-color: var(--text-muted); +} + +.theme-option.selected { + border-color: var(--accent); +} + +.theme-option input { + display: none; +} + +.theme-preview { + width: 80px; + height: 50px; + border-radius: 6px; +} + +.theme-preview.dark { + background: #0f1419; + border: 1px solid #2d3640; +} + +.theme-preview.light { + background: #fff; + border: 1px solid #d1d9e0; +} + +/* ===== Color Picker ===== */ +.color-picker { + display: flex; + gap: 8px; +} + +.color-option { + width: 32px; + height: 32px; + border-radius: 8px; + border: 2px solid transparent; + background: var(--color); + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.15s ease; +} + +.color-option:hover { + border-color: var(--text-muted); + transform: scale(1.1); +} + +.color-option.selected { + border-color: var(--text-primary); +} + +/* ===== Keybinding List ===== */ +.keybinding-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.keybinding-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} + +.keybinding-row:last-child { + border-bottom: none; +} + +.keybinding-action { + font-size: 13px; + color: var(--text-primary); +} + +.keybinding-key { + font-family: inherit; + font-size: 11px; + padding: 3px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.keybinding-key:hover { + border-color: var(--accent); + color: var(--text-primary); +} From 754812dce04d88e1d803228946bd340cbc025606 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:34 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(hosting):=20GitHub=E9=80=A3=E6=90=BA?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=EF=BC=88gh=20CLI=E3=83=A9=E3=83=83=E3=83=91?= =?UTF-8?q?=E3=83=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostingモジュールを新規作成。remote URL解析によるGitHub/GitLab自動検出、 gh CLIラッパーによるPR一覧/詳細・Issue一覧・CI/CDチェック取得、 デフォルトブランチ取得、ブラウザでのPR作成を実装。 クロスプラットフォーム対応(macOS/Linux/Windows)のブラウザ起動。 GitBackendトレイトにworkdir()を追加。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/hosting.rs | 69 +++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/git/backend.rs | 1 + src-tauri/src/git/git2_backend.rs | 4 + src-tauri/src/hosting/detector.rs | 146 +++++++++++ src-tauri/src/hosting/github.rs | 414 ++++++++++++++++++++++++++++++ src-tauri/src/hosting/mod.rs | 3 + src-tauri/src/hosting/types.rs | 198 ++++++++++++++ src-tauri/src/lib.rs | 8 + 9 files changed, 844 insertions(+) create mode 100644 src-tauri/src/commands/hosting.rs create mode 100644 src-tauri/src/hosting/detector.rs create mode 100644 src-tauri/src/hosting/github.rs create mode 100644 src-tauri/src/hosting/mod.rs create mode 100644 src-tauri/src/hosting/types.rs diff --git a/src-tauri/src/commands/hosting.rs b/src-tauri/src/commands/hosting.rs new file mode 100644 index 0000000..250dc1c --- /dev/null +++ b/src-tauri/src/commands/hosting.rs @@ -0,0 +1,69 @@ +use tauri::State; + +use crate::hosting::detector; +use crate::hosting::github; +use crate::hosting::types::{HostingInfo, Issue, PrDetail, PullRequest}; +use crate::state::AppState; + +fn get_repo_path(state: &State<'_, AppState>) -> Result { + let guard = state.repo.lock().map_err(|e| format!("Lock poisoned: {e}"))?; + let repo = guard.as_ref().ok_or("No repository opened")?; + Ok(repo.workdir().to_string_lossy().to_string()) +} + +#[tauri::command] +pub fn detect_hosting_provider(state: State<'_, AppState>) -> Result { + let guard = state.repo.lock().map_err(|e| format!("Lock poisoned: {e}"))?; + let repo = guard.as_ref().ok_or("No repository opened")?; + let remotes = repo.list_remotes().map_err(|e| e.to_string())?; + let remote = remotes.first().ok_or("No remotes found")?; + Ok(detector::detect_from_remote_url(&remote.url)) +} + +#[tauri::command] +pub fn list_pull_requests( + state: State<'_, AppState>, +) -> Result, String> { + let repo_path = get_repo_path(&state)?; + github::list_pull_requests(&repo_path) +} + +#[tauri::command] +pub fn get_pull_request_detail( + state: State<'_, AppState>, + number: u64, +) -> Result { + let repo_path = get_repo_path(&state)?; + github::get_pull_request_detail(&repo_path, number) +} + +#[tauri::command] +pub fn list_issues(state: State<'_, AppState>) -> Result, String> { + let repo_path = get_repo_path(&state)?; + github::list_issues(&repo_path) +} + +#[tauri::command] +pub fn get_default_branch(state: State<'_, AppState>) -> Result { + let repo_path = get_repo_path(&state)?; + github::get_default_branch(&repo_path) +} + +#[tauri::command] +pub fn create_pull_request_url( + state: State<'_, AppState>, + head: String, + base: String, +) -> Result { + let repo_path = get_repo_path(&state)?; + github::create_pull_request_url(&repo_path, &head, &base) +} + +#[tauri::command] +pub fn open_in_browser( + state: State<'_, AppState>, + url: String, +) -> Result<(), String> { + let repo_path = get_repo_path(&state)?; + github::open_in_browser(&repo_path, &url) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 190ced3..06031bb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod config; pub mod conflict; pub mod git; pub mod history; +pub mod hosting; pub mod rebase; pub mod remote; pub mod stash; diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index fc5ea16..d72128e 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -9,6 +9,7 @@ use crate::git::types::{ }; pub trait GitBackend: Send + Sync { + fn workdir(&self) -> &Path; fn status(&self) -> GitResult; fn diff(&self, path: Option<&Path>, options: &DiffOptions) -> GitResult>; fn stage(&self, path: &Path) -> GitResult<()>; diff --git a/src-tauri/src/git/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index 0def51f..cea2121 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -49,6 +49,10 @@ impl Git2Backend { } impl GitBackend for Git2Backend { + fn workdir(&self) -> &Path { + &self.workdir + } + fn status(&self) -> GitResult { let repo = self.repo.lock().unwrap(); diff --git a/src-tauri/src/hosting/detector.rs b/src-tauri/src/hosting/detector.rs new file mode 100644 index 0000000..bf44df3 --- /dev/null +++ b/src-tauri/src/hosting/detector.rs @@ -0,0 +1,146 @@ +use super::types::{HostingInfo, HostingProviderKind}; + +pub fn detect_from_remote_url(url: &str) -> HostingInfo { + let normalized = normalize_url(url); + + if let Some(info) = parse_github(&normalized) { + return info; + } + + if let Some(info) = parse_gitlab(&normalized) { + return info; + } + + HostingInfo { + provider: HostingProviderKind::Unknown, + owner: String::new(), + repo: String::new(), + url: url.to_string(), + } +} + +fn normalize_url(url: &str) -> String { + let url = url.trim(); + if let Some(stripped) = url.strip_suffix(".git") { + stripped.to_string() + } else { + url.to_string() + } +} + +fn parse_github(url: &str) -> Option { + // SSH: git@github.com:owner/repo + if let Some(rest) = url.strip_prefix("git@github.com:") { + return parse_owner_repo(rest, HostingProviderKind::Github, url); + } + + // HTTPS: https://github.com/owner/repo + if let Some(rest) = url + .strip_prefix("https://github.com/") + .or_else(|| url.strip_prefix("http://github.com/")) + { + return parse_owner_repo(rest, HostingProviderKind::Github, url); + } + + None +} + +fn parse_gitlab(url: &str) -> Option { + // SSH: git@gitlab.com:owner/repo + if let Some(rest) = url.strip_prefix("git@gitlab.com:") { + return parse_owner_repo(rest, HostingProviderKind::Gitlab, url); + } + + // HTTPS: https://gitlab.com/owner/repo + if let Some(rest) = url + .strip_prefix("https://gitlab.com/") + .or_else(|| url.strip_prefix("http://gitlab.com/")) + { + return parse_owner_repo(rest, HostingProviderKind::Gitlab, url); + } + + None +} + +fn parse_owner_repo( + path: &str, + provider: HostingProviderKind, + original_url: &str, +) -> Option { + let parts: Vec<&str> = path.splitn(2, '/').collect(); + if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { + Some(HostingInfo { + provider, + owner: parts[0].to_string(), + repo: parts[1].to_string(), + url: original_url.to_string(), + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_github_https() { + let info = detect_from_remote_url("https://github.com/HMasataka/rocket.git"); + assert_eq!(info.provider, HostingProviderKind::Github); + assert_eq!(info.owner, "HMasataka"); + assert_eq!(info.repo, "rocket"); + } + + #[test] + fn detects_github_ssh() { + let info = detect_from_remote_url("git@github.com:HMasataka/rocket.git"); + assert_eq!(info.provider, HostingProviderKind::Github); + assert_eq!(info.owner, "HMasataka"); + assert_eq!(info.repo, "rocket"); + } + + #[test] + fn detects_github_https_without_git_suffix() { + let info = detect_from_remote_url("https://github.com/owner/repo"); + assert_eq!(info.provider, HostingProviderKind::Github); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + } + + #[test] + fn detects_gitlab_https() { + let info = detect_from_remote_url("https://gitlab.com/owner/repo.git"); + assert_eq!(info.provider, HostingProviderKind::Gitlab); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + } + + #[test] + fn detects_gitlab_ssh() { + let info = detect_from_remote_url("git@gitlab.com:owner/repo.git"); + assert_eq!(info.provider, HostingProviderKind::Gitlab); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + } + + #[test] + fn returns_unknown_for_unrecognized_url() { + let info = detect_from_remote_url("https://bitbucket.org/owner/repo.git"); + assert_eq!(info.provider, HostingProviderKind::Unknown); + } + + #[test] + fn handles_empty_url() { + let info = detect_from_remote_url(""); + assert_eq!(info.provider, HostingProviderKind::Unknown); + } + + #[test] + fn handles_whitespace_trimming() { + let info = detect_from_remote_url(" https://github.com/owner/repo.git "); + assert_eq!(info.provider, HostingProviderKind::Github); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo, "repo"); + } +} diff --git a/src-tauri/src/hosting/github.rs b/src-tauri/src/hosting/github.rs new file mode 100644 index 0000000..538060a --- /dev/null +++ b/src-tauri/src/hosting/github.rs @@ -0,0 +1,414 @@ +use std::process::Command; + +use super::types::{ + CheckStatus, CiCheck, Issue, IssueState, PrDetail, PrLabel, PrState, PullRequest, ReviewState, + Reviewer, +}; + +pub fn list_pull_requests(repo_path: &str) -> Result, String> { + let output = Command::new("gh") + .args([ + "pr", + "list", + "--json", + "number,title,state,author,createdAt,updatedAt,headRefName,baseRefName,isDraft,labels,additions,deletions,changedFiles,body,url", + "--limit", + "30", + ]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to execute gh: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh pr list failed: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_pr_list_json(&stdout) +} + +pub fn get_pull_request_detail( + repo_path: &str, + number: u64, +) -> Result { + let output = Command::new("gh") + .args([ + "pr", + "view", + &number.to_string(), + "--json", + "number,title,state,author,createdAt,updatedAt,headRefName,baseRefName,isDraft,labels,additions,deletions,changedFiles,body,url,statusCheckRollup,reviews", + ]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to execute gh: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh pr view failed: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_pr_detail_json(&stdout) +} + +pub fn list_issues(repo_path: &str) -> Result, String> { + let output = Command::new("gh") + .args([ + "issue", + "list", + "--json", + "number,title,state,author,createdAt,labels,url", + "--limit", + "30", + ]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to execute gh: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh issue list failed: {stderr}")); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_issue_list_json(&stdout) +} + +pub fn get_default_branch(repo_path: &str) -> Result { + let output = Command::new("gh") + .args(["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to execute gh: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh repo view failed: {stderr}")); + } + + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() { + return Err("default branch not found".to_string()); + } + + Ok(branch) +} + +pub fn create_pull_request_url( + repo_path: &str, + head: &str, + base: &str, +) -> Result { + let output = Command::new("gh") + .args(["browse", "--no-browser", "-n"]) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to execute gh: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh browse failed: {stderr}")); + } + + let repo_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(format!( + "{repo_url}/compare/{base}...{head}?expand=1" + )) +} + +pub fn open_in_browser(repo_path: &str, url: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let cmd = "open"; + #[cfg(target_os = "linux")] + let cmd = "xdg-open"; + #[cfg(target_os = "windows")] + let cmd = "cmd"; + + #[cfg(target_os = "windows")] + let args = vec!["/C", "start", "", url]; + #[cfg(not(target_os = "windows"))] + let args = vec![url]; + + let output = Command::new(cmd) + .args(&args) + .current_dir(repo_path) + .output() + .map_err(|e| format!("failed to open browser: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("open failed: {stderr}")); + } + + Ok(()) +} + +fn parse_pr_list_json(json: &str) -> Result, String> { + let raw: Vec = + serde_json::from_str(json).map_err(|e| format!("failed to parse JSON: {e}"))?; + + raw.into_iter().map(|v| map_pr_from_gh_json(&v)).collect() +} + +fn parse_pr_detail_json(json: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("failed to parse JSON: {e}"))?; + + let pr = map_pr_from_gh_json(&v)?; + let checks = map_checks_from_gh_json(&v); + let reviewers = map_reviewers_from_gh_json(&v); + + Ok(PrDetail { + pull_request: pr, + checks, + reviewers, + }) +} + +fn parse_issue_list_json(json: &str) -> Result, String> { + let raw: Vec = + serde_json::from_str(json).map_err(|e| format!("failed to parse JSON: {e}"))?; + + raw.into_iter() + .map(|v| { + let number = v["number"] + .as_u64() + .ok_or("missing field: number")?; + let title = v["title"] + .as_str() + .ok_or("missing field: title")? + .to_string(); + let url = v["url"] + .as_str() + .ok_or("missing field: url")? + .to_string(); + + Ok(Issue { + number, + title, + state: match v["state"].as_str().unwrap_or("") { + "CLOSED" => IssueState::Closed, + _ => IssueState::Open, + }, + author: v["author"]["login"].as_str().unwrap_or("").to_string(), + created_at: v["createdAt"].as_str().unwrap_or("").to_string(), + labels: map_labels(&v["labels"]), + url, + }) + }) + .collect() +} + +fn map_pr_from_gh_json(v: &serde_json::Value) -> Result { + let number = v["number"] + .as_u64() + .ok_or("missing field: number")?; + let title = v["title"] + .as_str() + .ok_or("missing field: title")? + .to_string(); + let url = v["url"] + .as_str() + .ok_or("missing field: url")? + .to_string(); + + Ok(PullRequest { + number, + title, + state: match v["state"].as_str().unwrap_or("") { + "CLOSED" => PrState::Closed, + "MERGED" => PrState::Merged, + _ => PrState::Open, + }, + author: v["author"]["login"].as_str().unwrap_or("").to_string(), + created_at: v["createdAt"].as_str().unwrap_or("").to_string(), + updated_at: v["updatedAt"].as_str().unwrap_or("").to_string(), + head_branch: v["headRefName"].as_str().unwrap_or("").to_string(), + base_branch: v["baseRefName"].as_str().unwrap_or("").to_string(), + draft: v["isDraft"].as_bool().unwrap_or(false), + labels: map_labels(&v["labels"]), + additions: v["additions"].as_u64().unwrap_or(0), + deletions: v["deletions"].as_u64().unwrap_or(0), + changed_files: v["changedFiles"].as_u64().unwrap_or(0), + body: v["body"].as_str().unwrap_or("").to_string(), + url, + }) +} + +fn map_labels(labels: &serde_json::Value) -> Vec { + labels + .as_array() + .map(|arr| { + arr.iter() + .map(|l| PrLabel { + name: l["name"].as_str().unwrap_or("").to_string(), + color: l["color"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default() +} + +fn map_checks_from_gh_json(v: &serde_json::Value) -> Vec { + v["statusCheckRollup"] + .as_array() + .map(|arr| { + arr.iter() + .map(|c| CiCheck { + name: c["name"].as_str().unwrap_or("").to_string(), + status: match c["conclusion"].as_str().unwrap_or("") { + "SUCCESS" => CheckStatus::Success, + "FAILURE" => CheckStatus::Failure, + _ => match c["status"].as_str().unwrap_or("") { + "IN_PROGRESS" => CheckStatus::Running, + _ => CheckStatus::Pending, + }, + }, + description: c["description"].as_str().unwrap_or("").to_string(), + elapsed: String::new(), + url: c["detailsUrl"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default() +} + +fn map_reviewers_from_gh_json(v: &serde_json::Value) -> Vec { + v["reviews"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| Reviewer { + login: r["author"]["login"].as_str().unwrap_or("").to_string(), + state: match r["state"].as_str().unwrap_or("") { + "APPROVED" => ReviewState::Approved, + "CHANGES_REQUESTED" => ReviewState::ChangesRequested, + "COMMENTED" => ReviewState::Commented, + _ => ReviewState::Pending, + }, + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pr_list_json_parses_valid_json() { + let json = r#"[ + { + "number": 42, + "title": "Add auth", + "state": "OPEN", + "author": {"login": "yamada"}, + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "headRefName": "feature/auth", + "baseRefName": "main", + "isDraft": false, + "labels": [{"name": "bug", "color": "ff0000"}], + "additions": 100, + "deletions": 20, + "changedFiles": 5, + "body": "PR body", + "url": "https://github.com/owner/repo/pull/42" + } + ]"#; + let prs = parse_pr_list_json(json).unwrap(); + assert_eq!(prs.len(), 1); + assert_eq!(prs[0].number, 42); + assert_eq!(prs[0].title, "Add auth"); + assert_eq!(prs[0].state, PrState::Open); + assert_eq!(prs[0].author, "yamada"); + assert_eq!(prs[0].head_branch, "feature/auth"); + assert!(!prs[0].draft); + assert_eq!(prs[0].labels.len(), 1); + assert_eq!(prs[0].additions, 100); + } + + #[test] + fn parse_pr_list_json_handles_empty_array() { + let prs = parse_pr_list_json("[]").unwrap(); + assert!(prs.is_empty()); + } + + #[test] + fn parse_pr_list_json_handles_closed_state() { + let json = r#"[{"number":1,"title":"t","state":"CLOSED","author":{"login":"u"},"createdAt":"","updatedAt":"","headRefName":"","baseRefName":"","isDraft":false,"labels":[],"additions":0,"deletions":0,"changedFiles":0,"body":"","url":""}]"#; + let prs = parse_pr_list_json(json).unwrap(); + assert_eq!(prs[0].state, PrState::Closed); + } + + #[test] + fn parse_pr_list_json_handles_merged_state() { + let json = r#"[{"number":1,"title":"t","state":"MERGED","author":{"login":"u"},"createdAt":"","updatedAt":"","headRefName":"","baseRefName":"","isDraft":false,"labels":[],"additions":0,"deletions":0,"changedFiles":0,"body":"","url":""}]"#; + let prs = parse_pr_list_json(json).unwrap(); + assert_eq!(prs[0].state, PrState::Merged); + } + + #[test] + fn parse_pr_detail_json_parses_checks_and_reviews() { + let json = r#"{ + "number": 42, + "title": "Add auth", + "state": "OPEN", + "author": {"login": "yamada"}, + "createdAt": "", + "updatedAt": "", + "headRefName": "feature/auth", + "baseRefName": "main", + "isDraft": false, + "labels": [], + "additions": 100, + "deletions": 20, + "changedFiles": 5, + "body": "", + "url": "", + "statusCheckRollup": [ + {"name": "build", "conclusion": "SUCCESS", "status": "COMPLETED", "description": "Build", "detailsUrl": "https://ci.example.com/1"} + ], + "reviews": [ + {"author": {"login": "tanaka"}, "state": "APPROVED"} + ] + }"#; + let detail = parse_pr_detail_json(json).unwrap(); + assert_eq!(detail.pull_request.number, 42); + assert_eq!(detail.checks.len(), 1); + assert_eq!(detail.checks[0].name, "build"); + assert_eq!(detail.checks[0].status, CheckStatus::Success); + assert_eq!(detail.reviewers.len(), 1); + assert_eq!(detail.reviewers[0].login, "tanaka"); + assert_eq!(detail.reviewers[0].state, ReviewState::Approved); + } + + #[test] + fn parse_issue_list_json_parses_valid_json() { + let json = r#"[ + { + "number": 18, + "title": "Fix login", + "state": "OPEN", + "author": {"login": "tanaka"}, + "createdAt": "2025-01-01T00:00:00Z", + "labels": [], + "url": "https://github.com/owner/repo/issues/18" + } + ]"#; + let issues = parse_issue_list_json(json).unwrap(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].number, 18); + assert_eq!(issues[0].state, IssueState::Open); + } + + #[test] + fn parse_issue_list_json_handles_closed_state() { + let json = r#"[{"number":1,"title":"t","state":"CLOSED","author":{"login":"u"},"createdAt":"","labels":[],"url":""}]"#; + let issues = parse_issue_list_json(json).unwrap(); + assert_eq!(issues[0].state, IssueState::Closed); + } +} diff --git a/src-tauri/src/hosting/mod.rs b/src-tauri/src/hosting/mod.rs new file mode 100644 index 0000000..66897a6 --- /dev/null +++ b/src-tauri/src/hosting/mod.rs @@ -0,0 +1,3 @@ +pub mod detector; +pub mod github; +pub mod types; diff --git a/src-tauri/src/hosting/types.rs b/src-tauri/src/hosting/types.rs new file mode 100644 index 0000000..80184b9 --- /dev/null +++ b/src-tauri/src/hosting/types.rs @@ -0,0 +1,198 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum HostingProviderKind { + Github, + Gitlab, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HostingInfo { + pub provider: HostingProviderKind, + pub owner: String, + pub repo: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum PrState { + Open, + Closed, + Merged, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrLabel { + pub name: String, + pub color: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequest { + pub number: u64, + pub title: String, + pub state: PrState, + pub author: String, + pub created_at: String, + pub updated_at: String, + pub head_branch: String, + pub base_branch: String, + pub draft: bool, + pub labels: Vec, + pub additions: u64, + pub deletions: u64, + pub changed_files: u64, + pub body: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum IssueState { + Open, + Closed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub number: u64, + pub title: String, + pub state: IssueState, + pub author: String, + pub created_at: String, + pub labels: Vec, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Success, + Failure, + Pending, + Running, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CiCheck { + pub name: String, + pub status: CheckStatus, + pub description: String, + pub elapsed: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrDetail { + pub pull_request: PullRequest, + pub checks: Vec, + pub reviewers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewState { + Approved, + ChangesRequested, + Pending, + Commented, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reviewer { + pub login: String, + pub state: ReviewState, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosting_provider_kind_serializes_as_lowercase() { + let kind = HostingProviderKind::Github; + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(json, "\"github\""); + } + + #[test] + fn pr_state_serializes_as_lowercase() { + assert_eq!( + serde_json::to_string(&PrState::Open).unwrap(), + "\"open\"" + ); + assert_eq!( + serde_json::to_string(&PrState::Merged).unwrap(), + "\"merged\"" + ); + } + + #[test] + fn check_status_serializes_as_lowercase() { + assert_eq!( + serde_json::to_string(&CheckStatus::Success).unwrap(), + "\"success\"" + ); + assert_eq!( + serde_json::to_string(&CheckStatus::Running).unwrap(), + "\"running\"" + ); + } + + #[test] + fn review_state_serializes_as_lowercase() { + assert_eq!( + serde_json::to_string(&ReviewState::Approved).unwrap(), + "\"approved\"" + ); + assert_eq!( + serde_json::to_string(&ReviewState::ChangesRequested).unwrap(), + "\"changes_requested\"" + ); + } + + #[test] + fn pull_request_deserializes_from_json() { + let json = r#"{ + "number": 42, + "title": "Add auth", + "state": "open", + "author": "yamada", + "created_at": "2025-01-01", + "updated_at": "2025-01-02", + "head_branch": "feature/auth", + "base_branch": "main", + "draft": false, + "labels": [{"name": "bug", "color": "ff0000"}], + "additions": 100, + "deletions": 20, + "changed_files": 5, + "body": "PR body", + "url": "https://github.com/owner/repo/pull/42" + }"#; + let pr: PullRequest = serde_json::from_str(json).unwrap(); + assert_eq!(pr.number, 42); + assert_eq!(pr.title, "Add auth"); + assert_eq!(pr.state, PrState::Open); + assert_eq!(pr.labels.len(), 1); + } + + #[test] + fn issue_deserializes_from_json() { + let json = r#"{ + "number": 18, + "title": "Fix login", + "state": "open", + "author": "tanaka", + "created_at": "2025-01-01", + "labels": [], + "url": "https://github.com/owner/repo/issues/18" + }"#; + let issue: Issue = serde_json::from_str(json).unwrap(); + assert_eq!(issue.number, 18); + assert_eq!(issue.state, IssueState::Open); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2af8cff..5513297 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod ai; pub mod commands; mod config; pub mod git; +pub mod hosting; pub mod state; mod watcher; @@ -150,6 +151,13 @@ pub fn run() { commands::ai::generate_pr_description, commands::ai::get_ai_config, commands::ai::save_ai_config, + commands::hosting::detect_hosting_provider, + commands::hosting::list_pull_requests, + commands::hosting::get_pull_request_detail, + commands::hosting::list_issues, + commands::hosting::get_default_branch, + commands::hosting::create_pull_request_url, + commands::hosting::open_in_browser, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From b6ef83bb43e68c4b953d0c9214fb091d7c4da837 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:40 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(hosting):=20GitHub=E9=80=A3=E6=90=BA?= =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88=E3=82=A8=E3=83=B3=E3=83=89?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88PR/Issue/CI/CD=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostingPageを新規作成しPR一覧/詳細・Issue一覧・CI/CDチェック表示を実装。 サイドバーにHostingセクションを追加しページ遷移に対応。 hostingStoreでホスティング状態管理、App.tsxでテーマ統合とルーティング追加。 PR作成モーダルでデフォルトブランチを自動取得。 Co-Authored-By: Claude Opus 4.6 --- designs/hosting/styles.css | 2 +- src/App.tsx | 11 + src/components/organisms/Sidebar.tsx | 18 + src/main.tsx | 1 + src/pages/hosting/IssueList.tsx | 49 ++ src/pages/hosting/PrDetailPanel.tsx | 163 +++++++ src/pages/hosting/PrList.tsx | 62 +++ src/pages/hosting/index.tsx | 151 ++++++ src/services/hosting.ts | 91 ++++ src/stores/hostingStore.ts | 112 +++++ src/stores/uiStore.ts | 3 +- src/styles/hosting.css | 658 +++++++++++++++++++++++++++ 12 files changed, 1319 insertions(+), 2 deletions(-) create mode 100644 src/pages/hosting/IssueList.tsx create mode 100644 src/pages/hosting/PrDetailPanel.tsx create mode 100644 src/pages/hosting/PrList.tsx create mode 100644 src/pages/hosting/index.tsx create mode 100644 src/services/hosting.ts create mode 100644 src/stores/hostingStore.ts create mode 100644 src/styles/hosting.css diff --git a/designs/hosting/styles.css b/designs/hosting/styles.css index b1f49c0..222a41c 100644 --- a/designs/hosting/styles.css +++ b/designs/hosting/styles.css @@ -429,7 +429,7 @@ .reviewer-status.approved { color: var(--success); } .reviewer-status.pending { color: var(--text-muted); } -.reviewer-status.changes-requested { color: var(--warning); } +.reviewer-status.changes_requested { color: var(--warning); } /* Labels */ .pr-detail-labels { diff --git a/src/App.tsx b/src/App.tsx index f5d4272..4b97aa5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,15 +6,18 @@ import { TagsModal } from "./components/organisms/TagsModal"; import { ToastContainer } from "./components/organisms/ToastContainer"; import { AppShell } from "./components/templates/AppShell"; import { useFileWatcher } from "./hooks/useFileWatcher"; +import { useTheme } from "./hooks/useTheme"; import { BlamePage } from "./pages/blame"; import { BranchesPage } from "./pages/branches"; import { ChangesPage } from "./pages/changes"; import { ConflictModal } from "./pages/conflict"; import { FileHistoryPage } from "./pages/file-history"; import { HistoryPage } from "./pages/history"; +import { HostingPage } from "./pages/hosting"; import { RebasePage } from "./pages/rebase"; import { StashPage } from "./pages/stash"; import type { PullOption } from "./services/git"; +import { useConfigStore } from "./stores/configStore"; import { useGitStore } from "./stores/gitStore"; import { useUIStore } from "./stores/uiStore"; @@ -33,13 +36,19 @@ export function App() { const fetchMergeState = useGitStore((s) => s.fetchMergeState); const fetchRebaseState = useGitStore((s) => s.fetchRebaseState); const fetchStashes = useGitStore((s) => s.fetchStashes); + const loadConfig = useConfigStore((s) => s.loadConfig); const addToast = useUIStore((s) => s.addToast); const activePage = useUIStore((s) => s.activePage); const activeModal = useUIStore((s) => s.activeModal); const openModal = useUIStore((s) => s.openModal); const closeModal = useUIStore((s) => s.closeModal); + useTheme(); + useEffect(() => { + loadConfig().catch((e: unknown) => { + addToast(String(e), "error"); + }); fetchBranch().catch((e: unknown) => { addToast(String(e), "error"); }); @@ -56,6 +65,7 @@ export function App() { addToast(String(e), "error"); }); }, [ + loadConfig, fetchBranch, fetchRemotes, fetchStashes, @@ -148,6 +158,7 @@ export function App() { {activePage === "file-history" && } {activePage === "stash" && } {activePage === "rebase" && } + {activePage === "hosting" && } {activeModal === "remotes" && } diff --git a/src/components/organisms/Sidebar.tsx b/src/components/organisms/Sidebar.tsx index 6cd90b8..a0af85d 100644 --- a/src/components/organisms/Sidebar.tsx +++ b/src/components/organisms/Sidebar.tsx @@ -98,6 +98,24 @@ export function Sidebar({ changesCount }: SidebarProps) { {stashCount > 0 && {stashCount}} +
+
Hosting
+ +
); } diff --git a/src/main.tsx b/src/main.tsx index f18354b..a8a5fac 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import "./styles/tags.css"; import "./styles/conflict.css"; import "./styles/ai.css"; import "./styles/settings.css"; +import "./styles/hosting.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/src/pages/hosting/IssueList.tsx b/src/pages/hosting/IssueList.tsx new file mode 100644 index 0000000..ba57a3d --- /dev/null +++ b/src/pages/hosting/IssueList.tsx @@ -0,0 +1,49 @@ +import type { Issue } from "../../services/hosting"; + +interface IssueListProps { + issues: Issue[]; +} + +export function IssueList({ issues }: IssueListProps) { + return ( +
+ {issues.map((issue) => ( +
+
+ + + + +
+
+
+ {issue.title} +
+
+ #{issue.number} + {issue.author} + {issue.created_at} +
+
+ {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+ ))} + {issues.length === 0 && ( +
No issues found
+ )} +
+ ); +} diff --git a/src/pages/hosting/PrDetailPanel.tsx b/src/pages/hosting/PrDetailPanel.tsx new file mode 100644 index 0000000..ab61426 --- /dev/null +++ b/src/pages/hosting/PrDetailPanel.tsx @@ -0,0 +1,163 @@ +import type { PrDetail } from "../../services/hosting"; + +interface PrDetailPanelProps { + detail: PrDetail | null; + loading: boolean; + onViewOnGitHub: () => void; +} + +function CheckIcon({ status }: { status: string }) { + if (status === "success") { + return ( + + + + ); + } + return ( + + + + ); +} + +export function PrDetailPanel({ + detail, + loading, + onViewOnGitHub, +}: PrDetailPanelProps) { + if (!detail) { + return ( +
+
+ {loading ? "Loading..." : "Select a pull request to view details"} +
+
+ ); + } + + const pr = detail.pull_request; + + return ( +
+
+
+
+

{pr.title}

+ + {pr.state} + +
+
+ #{pr.number} + + {pr.head_branch} + + + + {pr.base_branch} + +
+
+ + {pr.body && ( +
+
Description
+
+

{pr.body}

+
+
+ )} + + {detail.reviewers.length > 0 && ( +
+
Reviewers
+
+ {detail.reviewers.map((reviewer) => ( +
+
+ {reviewer.login[0]?.toUpperCase()} +
+ {reviewer.login} + + {reviewer.state} + +
+ ))} +
+
+ )} + + {pr.labels.length > 0 && ( +
+
Labels
+
+ {pr.labels.map((label) => ( + + {label.name} + + ))} +
+
+ )} + + {detail.checks.length > 0 && ( +
+
Checks
+
+ {detail.checks.map((check) => ( +
+ + + + {check.name} + {check.description} +
+ ))} +
+
+ )} + +
+
Changes
+
+ + {pr.changed_files} files changed + + +{pr.additions} + -{pr.deletions} +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/pages/hosting/PrList.tsx b/src/pages/hosting/PrList.tsx new file mode 100644 index 0000000..812a3af --- /dev/null +++ b/src/pages/hosting/PrList.tsx @@ -0,0 +1,62 @@ +import type { PullRequest } from "../../services/hosting"; + +interface PrListProps { + pullRequests: PullRequest[]; + selectedNumber: number | null; + onSelect: (number: number) => void; +} + +export function PrList({ + pullRequests, + selectedNumber, + onSelect, +}: PrListProps) { + return ( +
+ {pullRequests.map((pr) => ( + + ))} + {pullRequests.length === 0 && ( +
No pull requests found
+ )} +
+ ); +} diff --git a/src/pages/hosting/index.tsx b/src/pages/hosting/index.tsx new file mode 100644 index 0000000..f7345a1 --- /dev/null +++ b/src/pages/hosting/index.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect } from "react"; +import { createPullRequestUrl, openInBrowser } from "../../services/hosting"; +import { useGitStore } from "../../stores/gitStore"; +import { useHostingStore } from "../../stores/hostingStore"; +import { useUIStore } from "../../stores/uiStore"; +import { IssueList } from "./IssueList"; +import { PrDetailPanel } from "./PrDetailPanel"; +import { PrList } from "./PrList"; + +export function HostingPage() { + const hostingInfo = useHostingStore((s) => s.hostingInfo); + const pullRequests = useHostingStore((s) => s.pullRequests); + const issues = useHostingStore((s) => s.issues); + const selectedPrDetail = useHostingStore((s) => s.selectedPrDetail); + const selectedPrNumber = useHostingStore((s) => s.selectedPrNumber); + const activeTab = useHostingStore((s) => s.activeTab); + const loading = useHostingStore((s) => s.loading); + const defaultBranch = useHostingStore((s) => s.defaultBranch); + const fetchHostingInfo = useHostingStore((s) => s.fetchHostingInfo); + const fetchDefaultBranch = useHostingStore((s) => s.fetchDefaultBranch); + const fetchPullRequests = useHostingStore((s) => s.fetchPullRequests); + const fetchIssues = useHostingStore((s) => s.fetchIssues); + const selectPr = useHostingStore((s) => s.selectPr); + const setActiveTab = useHostingStore((s) => s.setActiveTab); + const currentBranch = useGitStore((s) => s.currentBranch); + const addToast = useUIStore((s) => s.addToast); + + useEffect(() => { + fetchHostingInfo().catch((e: unknown) => { + addToast(String(e), "error"); + }); + fetchDefaultBranch().catch(() => {}); + fetchPullRequests().catch((e: unknown) => { + addToast(String(e), "error"); + }); + fetchIssues().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [ + fetchHostingInfo, + fetchDefaultBranch, + fetchPullRequests, + fetchIssues, + addToast, + ]); + + const handleOpenInBrowser = useCallback(() => { + if (!hostingInfo) return; + openInBrowser(hostingInfo.url).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [hostingInfo, addToast]); + + const handleCreatePr = useCallback(() => { + if (!currentBranch) return; + const base = defaultBranch ?? "main"; + createPullRequestUrl(currentBranch, base) + .then((url) => openInBrowser(url)) + .catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [currentBranch, defaultBranch, addToast]); + + const handleViewOnGitHub = useCallback(() => { + if (!hostingInfo || !selectedPrNumber) return; + const url = `${hostingInfo.url}/pull/${selectedPrNumber}`; + openInBrowser(url).catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [hostingInfo, selectedPrNumber, addToast]); + + return ( +
+
+
+
+ + + + {hostingInfo?.provider ?? "GitHub"} +
+ + {hostingInfo + ? `${hostingInfo.owner}/${hostingInfo.repo}` + : "Loading..."} + +
+
+ + +
+
+ +
+ + +
+ +
+ {activeTab === "pulls" && ( + <> + + + + )} + {activeTab === "issues" && } +
+
+ ); +} diff --git a/src/services/hosting.ts b/src/services/hosting.ts new file mode 100644 index 0000000..cc65a9e --- /dev/null +++ b/src/services/hosting.ts @@ -0,0 +1,91 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface HostingInfo { + provider: string; + owner: string; + repo: string; + url: string; +} + +export interface PrLabel { + name: string; + color: string; +} + +export interface PullRequest { + number: number; + title: string; + state: string; + author: string; + head_branch: string; + base_branch: string; + draft: boolean; + created_at: string; + updated_at: string; + labels: PrLabel[]; + additions: number; + deletions: number; + changed_files: number; + body: string; + url: string; +} + +export interface Reviewer { + login: string; + state: string; +} + +export interface CiCheck { + name: string; + status: string; + description: string; + elapsed: string; + url: string; +} + +export interface PrDetail { + pull_request: PullRequest; + checks: CiCheck[]; + reviewers: Reviewer[]; +} + +export interface Issue { + number: number; + title: string; + state: string; + author: string; + labels: PrLabel[]; + created_at: string; + url: string; +} + +export function detectHostingProvider(): Promise { + return invoke("detect_hosting_provider"); +} + +export function listPullRequests(): Promise { + return invoke("list_pull_requests"); +} + +export function getPullRequestDetail(number: number): Promise { + return invoke("get_pull_request_detail", { number }); +} + +export function listIssues(): Promise { + return invoke("list_issues"); +} + +export function getDefaultBranch(): Promise { + return invoke("get_default_branch"); +} + +export function createPullRequestUrl( + head: string, + base: string, +): Promise { + return invoke("create_pull_request_url", { head, base }); +} + +export function openInBrowser(url: string): Promise { + return invoke("open_in_browser", { url }); +} diff --git a/src/stores/hostingStore.ts b/src/stores/hostingStore.ts new file mode 100644 index 0000000..bbf9a68 --- /dev/null +++ b/src/stores/hostingStore.ts @@ -0,0 +1,112 @@ +import { create } from "zustand"; +import type { + HostingInfo, + Issue, + PrDetail, + PullRequest, +} from "../services/hosting"; +import { + detectHostingProvider, + getDefaultBranch, + getPullRequestDetail, + listIssues, + listPullRequests, +} from "../services/hosting"; + +type HostingTab = "pulls" | "issues"; + +interface HostingState { + hostingInfo: HostingInfo | null; + defaultBranch: string | null; + pullRequests: PullRequest[]; + issues: Issue[]; + selectedPrDetail: PrDetail | null; + selectedPrNumber: number | null; + activeTab: HostingTab; + loading: boolean; + error: string | null; +} + +interface HostingActions { + fetchHostingInfo: () => Promise; + fetchDefaultBranch: () => Promise; + fetchPullRequests: () => Promise; + fetchIssues: () => Promise; + selectPr: (number: number) => Promise; + setActiveTab: (tab: HostingTab) => void; + clearError: () => void; +} + +export const useHostingStore = create((set) => ({ + hostingInfo: null, + defaultBranch: null, + pullRequests: [], + issues: [], + selectedPrDetail: null, + selectedPrNumber: null, + activeTab: "pulls", + loading: false, + error: null, + + fetchHostingInfo: async () => { + set({ loading: true, error: null }); + try { + const hostingInfo = await detectHostingProvider(); + set({ hostingInfo, loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + fetchDefaultBranch: async () => { + try { + const defaultBranch = await getDefaultBranch(); + set({ defaultBranch }); + } catch (_e) { + // デフォルトブランチ取得失敗は致命的ではないのでエラーを設定しない + set({ defaultBranch: null }); + } + }, + + fetchPullRequests: async () => { + set({ loading: true, error: null }); + try { + const pullRequests = await listPullRequests(); + set({ pullRequests, loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + fetchIssues: async () => { + set({ loading: true, error: null }); + try { + const issues = await listIssues(); + set({ issues, loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + selectPr: async (number: number) => { + set({ selectedPrNumber: number, loading: true }); + try { + const selectedPrDetail = await getPullRequestDetail(number); + set({ selectedPrDetail, loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + setActiveTab: (tab: HostingTab) => { + set({ activeTab: tab }); + }, + + clearError: () => { + set({ error: null }); + }, +})); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 8b1f78b..4f294ef 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -8,7 +8,8 @@ export type PageId = | "blame" | "file-history" | "stash" - | "rebase"; + | "rebase" + | "hosting"; interface BlameTarget { path: string; diff --git a/src/styles/hosting.css b/src/styles/hosting.css new file mode 100644 index 0000000..9766733 --- /dev/null +++ b/src/styles/hosting.css @@ -0,0 +1,658 @@ +/* ===== Hosting Layout ===== */ +.hosting-layout { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* ===== Header ===== */ +.hosting-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 20px; + height: 48px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} + +.hosting-info { + display: flex; + align-items: center; + gap: 12px; +} + +.provider-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; +} + +.provider-badge svg { + width: 16px; + height: 16px; +} + +.provider-badge.github { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.provider-badge.gitlab { + background: rgba(252, 109, 38, 0.15); + color: #fc6d26; +} + +.hosting-repo { + font-family: "JetBrains Mono", monospace; + font-size: 13px; + color: var(--text-secondary); +} + +.hosting-actions { + display: flex; + gap: 8px; +} + +/* ===== Tab Bar ===== */ +.hosting-tabs { + display: flex; + gap: 0; + padding: 0 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.hosting-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.hosting-tab svg { + width: 14px; + height: 14px; +} + +.hosting-tab:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.hosting-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.tab-count { + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); +} + +.hosting-tab.active .tab-count { + background: var(--accent-dim); + color: var(--accent); +} + +/* ===== Content Two-Column ===== */ +.hosting-content { + display: grid; + grid-template-columns: 1fr 1fr; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ===== List Panel ===== */ +.hosting-list-panel { + display: flex; + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--border); +} + +/* ===== PR Item ===== */ +.pr-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border: none; + border-bottom: 1px solid var(--border); + background: none; + color: inherit; + font-family: inherit; + text-align: left; + width: 100%; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.pr-item:hover { + background: var(--bg-hover); +} + +.pr-item.selected { + background: var(--accent-dim); + border-left: 3px solid var(--accent); +} + +.pr-item.selected:hover { + background: var(--accent-dim); +} + +.pr-status-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +.pr-status-icon svg { + width: 18px; + height: 18px; +} + +.pr-status-icon.open { + color: var(--success); +} + +.pr-status-icon.merged { + color: var(--purple); +} + +.pr-status-icon.closed { + color: var(--danger); +} + +.pr-status-icon.draft { + color: var(--text-muted); +} + +.pr-info { + flex: 1; + min-width: 0; +} + +.pr-title-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.pr-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pr-draft-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-muted); + border: 1px solid var(--border); + white-space: nowrap; + flex-shrink: 0; +} + +.pr-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--text-muted); +} + +.pr-number { + font-family: "JetBrains Mono", monospace; + color: var(--accent); +} + +/* PR Labels */ +.pr-labels { + display: flex; + flex-wrap: wrap; + gap: 4px; + flex-shrink: 0; + margin-top: 2px; +} + +.pr-label { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + background: var(--accent-dim); + color: var(--accent); + white-space: nowrap; +} + +/* ===== Empty State ===== */ +.hosting-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 13px; +} + +/* ===== Detail Panel ===== */ +.hosting-detail-panel { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.pr-detail { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +/* ===== PR Detail Header ===== */ +.pr-detail-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.pr-detail-title-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 8px; +} + +.pr-detail-title { + font-size: 16px; + font-weight: 600; + line-height: 1.4; + margin: 0; + flex: 1; +} + +.pr-status-badge { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 12px; + white-space: nowrap; + flex-shrink: 0; +} + +.pr-status-badge.open { + background: var(--success-dim); + color: var(--success); + border: 1px solid rgba(63, 185, 80, 0.3); +} + +.pr-status-badge.merged { + background: var(--purple-dim); + color: var(--purple); + border: 1px solid rgba(168, 85, 247, 0.3); +} + +.pr-status-badge.closed { + background: var(--danger-dim); + color: var(--danger); + border: 1px solid rgba(248, 81, 73, 0.3); +} + +.pr-detail-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 12px; + color: var(--text-muted); +} + +.pr-detail-number { + font-family: "JetBrains Mono", monospace; + color: var(--text-secondary); +} + +.pr-detail-branch { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.pr-detail-branch svg { + width: 14px; + height: 14px; + color: var(--text-muted); +} + +.branch-from, +.branch-to { + font-family: "JetBrains Mono", monospace; + font-size: 11px; + padding: 2px 8px; + background: var(--bg-tertiary); + border-radius: 4px; + color: var(--accent); +} + +/* ===== Detail Sections ===== */ +.pr-detail-section { + margin-bottom: 20px; +} + +.pr-detail-section-title { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +/* Description Body */ +.pr-detail-body { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + border-left: 3px solid var(--accent); +} + +.pr-detail-body p { + margin-bottom: 8px; +} + +.pr-detail-body p:last-child { + margin-bottom: 0; +} + +/* Reviewers */ +.reviewer-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reviewer-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-secondary); + border-radius: 6px; +} + +.reviewer-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + flex-shrink: 0; +} + +.reviewer-name { + flex: 1; + font-size: 13px; +} + +.reviewer-status { + font-size: 11px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.reviewer-status.approved { + color: var(--success); +} + +.reviewer-status.pending { + color: var(--text-muted); +} + +.reviewer-status.changes_requested { + color: var(--warning); +} + +/* Labels */ +.pr-detail-labels { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* CI/CD Checks */ +.checks-list { + display: flex; + flex-direction: column; + gap: 4px; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.check-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + font-size: 12px; + transition: background-color 0.15s ease; +} + +.check-item:hover { + background: var(--bg-hover); +} + +.check-item + .check-item { + border-top: 1px solid var(--border); +} + +.check-icon { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.check-icon svg { + width: 16px; + height: 16px; +} + +.check-item.success .check-icon { + color: var(--success); +} + +.check-item.failure .check-icon { + color: var(--danger); +} + +.check-name { + font-family: "JetBrains Mono", monospace; + font-weight: 600; + font-size: 12px; +} + +.check-desc { + flex: 1; + color: var(--text-muted); +} + +/* Changes Summary */ +.pr-changes-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 6px; + font-size: 12px; +} + +.pr-changes-stat { + color: var(--text-secondary); +} + +.pr-changes-stat strong { + color: var(--text-primary); +} + +.pr-changes-add { + font-family: "JetBrains Mono", monospace; + font-weight: 600; + color: var(--success); +} + +.pr-changes-del { + font-family: "JetBrains Mono", monospace; + font-weight: 600; + color: var(--danger); +} + +/* ===== Footer ===== */ +.hosting-footer-actions { + display: flex; + gap: 8px; +} + +/* ===== Create PR Modal ===== */ +.create-pr-modal { + width: 640px; + max-width: 90vw; +} + +.pr-form-section { + margin-bottom: 20px; +} + +.pr-form-section:last-child { + margin-bottom: 0; +} + +.pr-form-label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.pr-form-input { + width: 100%; + padding: 10px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-family: inherit; + font-size: 14px; + font-weight: 500; + transition: border-color 0.15s ease; +} + +.pr-form-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +.pr-form-input::placeholder { + color: var(--text-muted); + opacity: 0.6; +} + +.pr-form-textarea { + width: 100%; + padding: 10px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-secondary); + font-family: "JetBrains Mono", monospace; + font-size: 12px; + line-height: 1.6; + resize: vertical; + transition: border-color 0.15s ease; +} + +.pr-form-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); + color: var(--text-primary); +} + +/* ===== Responsive ===== */ +@media (max-width: 1023px) { + .hosting-content { + grid-template-columns: 1fr; + } + + .hosting-list-panel { + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 40%; + } + + .hosting-detail-panel { + max-height: 60%; + } +} + +@media (max-width: 767px) { + .hosting-header { + flex-direction: column; + height: auto; + padding: 12px 16px; + gap: 8px; + align-items: flex-start; + } + + .pr-detail-title-row { + flex-direction: column; + } + + .pr-labels { + display: none; + } +} From ac1e808e64f40fac4d713d157b4745c0c8c73fff Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:29:47 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test:=20configStore=E3=83=BBhostingStore?= =?UTF-8?q?=E3=81=AE=E3=83=A6=E3=83=8B=E3=83=83=E3=83=88=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configStore(8テスト): loadConfig/saveConfig/updateAppearanceの成功・失敗・ ローディング・null guard。hostingStore(14テスト): fetchHostingInfo/ fetchPullRequests/fetchIssues/selectPr/fetchDefaultBranchの成功・失敗、 setActiveTab、clearError。 Co-Authored-By: Claude Opus 4.6 --- src/stores/__tests__/configStore.test.ts | 159 ++++++++++++++ src/stores/__tests__/hostingStore.test.ts | 244 ++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/stores/__tests__/configStore.test.ts create mode 100644 src/stores/__tests__/hostingStore.test.ts diff --git a/src/stores/__tests__/configStore.test.ts b/src/stores/__tests__/configStore.test.ts new file mode 100644 index 0000000..dfeb0a2 --- /dev/null +++ b/src/stores/__tests__/configStore.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { useConfigStore } from "../configStore"; + +const mockedInvoke = vi.mocked(invoke); + +const mockConfig = { + last_opened_repo: "/path/to/repo", + ai: { + commit_message_style: "conventional", + commit_message_language: "en", + provider_priority: ["openai"], + prefer_local_llm: false, + exclude_patterns: [], + }, + appearance: { + theme: "dark", + color_theme: "cobalt", + ui_font_size: 13, + sidebar_position: "left", + tab_style: "default", + }, + editor: { + font_family: "JetBrains Mono", + font_size: 14, + line_height: 20, + show_line_numbers: true, + word_wrap: false, + show_whitespace: false, + minimap: true, + indent_style: "spaces", + tab_size: 2, + }, + keybindings: { + preset: "default", + }, + tools: { + diff_tool: "", + merge_tool: "", + terminal: "", + editor: "", + git_path: "git", + auto_fetch_on_open: true, + auto_fetch_interval: 300, + open_in_editor_on_double_click: false, + }, +}; + +describe("configStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useConfigStore.setState({ + config: null, + loading: false, + error: null, + }); + }); + + describe("loadConfig", () => { + it("sets config on success", async () => { + mockedInvoke.mockResolvedValueOnce(mockConfig); + + await useConfigStore.getState().loadConfig(); + + const state = useConfigStore.getState(); + expect(state.config).toEqual(mockConfig); + expect(state.loading).toBe(false); + expect(state.error).toBeNull(); + }); + + it("sets loading to true during fetch", async () => { + let resolveFn: (v: unknown) => void; + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + mockedInvoke.mockReturnValueOnce(promise); + + const loadPromise = useConfigStore.getState().loadConfig(); + expect(useConfigStore.getState().loading).toBe(true); + + resolveFn!(mockConfig); + await loadPromise; + expect(useConfigStore.getState().loading).toBe(false); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("config error")); + + await expect(useConfigStore.getState().loadConfig()).rejects.toThrow(); + + const state = useConfigStore.getState(); + expect(state.error).toContain("config error"); + expect(state.loading).toBe(false); + }); + }); + + describe("saveConfig", () => { + it("updates config on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useConfigStore.getState().saveConfig(mockConfig); + + expect(useConfigStore.getState().config).toEqual(mockConfig); + expect(mockedInvoke).toHaveBeenCalledWith("save_config", { + config: mockConfig, + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("save error")); + + await expect( + useConfigStore.getState().saveConfig(mockConfig), + ).rejects.toThrow(); + + expect(useConfigStore.getState().error).toContain("save error"); + }); + }); + + describe("updateAppearance", () => { + it("saves updated appearance on success", async () => { + useConfigStore.setState({ config: mockConfig }); + mockedInvoke.mockResolvedValueOnce(undefined); + + const newAppearance = { ...mockConfig.appearance, theme: "light" }; + await useConfigStore.getState().updateAppearance(newAppearance); + + const state = useConfigStore.getState(); + expect(state.config?.appearance.theme).toBe("light"); + expect(mockedInvoke).toHaveBeenCalledWith("save_config", { + config: { ...mockConfig, appearance: newAppearance }, + }); + }); + + it("does nothing when config is null", async () => { + useConfigStore.setState({ config: null }); + + await useConfigStore.getState().updateAppearance(mockConfig.appearance); + + expect(mockedInvoke).not.toHaveBeenCalled(); + }); + + it("sets error on failure", async () => { + useConfigStore.setState({ config: mockConfig }); + mockedInvoke.mockRejectedValueOnce(new Error("appearance error")); + + await expect( + useConfigStore.getState().updateAppearance(mockConfig.appearance), + ).rejects.toThrow(); + + expect(useConfigStore.getState().error).toContain("appearance error"); + }); + }); +}); diff --git a/src/stores/__tests__/hostingStore.test.ts b/src/stores/__tests__/hostingStore.test.ts new file mode 100644 index 0000000..38c1f7e --- /dev/null +++ b/src/stores/__tests__/hostingStore.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { useHostingStore } from "../hostingStore"; + +const mockedInvoke = vi.mocked(invoke); + +describe("hostingStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useHostingStore.setState({ + hostingInfo: null, + defaultBranch: null, + pullRequests: [], + issues: [], + selectedPrDetail: null, + selectedPrNumber: null, + activeTab: "pulls", + loading: false, + error: null, + }); + }); + + describe("fetchHostingInfo", () => { + it("sets hostingInfo on success", async () => { + const mockInfo = { + provider: "github", + owner: "octocat", + repo: "hello-world", + url: "https://github.com/octocat/hello-world", + }; + mockedInvoke.mockResolvedValueOnce(mockInfo); + + await useHostingStore.getState().fetchHostingInfo(); + + const state = useHostingStore.getState(); + expect(state.hostingInfo).toEqual(mockInfo); + expect(state.loading).toBe(false); + }); + + it("sets loading to true during fetch", async () => { + let resolveFn: ((v: unknown) => void) | undefined; + const promise = new Promise((resolve) => { + resolveFn = resolve; + }); + mockedInvoke.mockReturnValueOnce(promise); + + const fetchPromise = useHostingStore.getState().fetchHostingInfo(); + expect(useHostingStore.getState().loading).toBe(true); + + resolveFn?.({ + provider: "github", + owner: "octocat", + repo: "hello-world", + url: "https://github.com/octocat/hello-world", + }); + await fetchPromise; + expect(useHostingStore.getState().loading).toBe(false); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("hosting error")); + + await expect( + useHostingStore.getState().fetchHostingInfo(), + ).rejects.toThrow(); + + const state = useHostingStore.getState(); + expect(state.error).toContain("hosting error"); + expect(state.loading).toBe(false); + }); + }); + + describe("fetchDefaultBranch", () => { + it("sets defaultBranch on success", async () => { + mockedInvoke.mockResolvedValueOnce("main"); + + await useHostingStore.getState().fetchDefaultBranch(); + + expect(useHostingStore.getState().defaultBranch).toBe("main"); + }); + + it("sets defaultBranch to null on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("gh not available")); + + await useHostingStore.getState().fetchDefaultBranch(); + + expect(useHostingStore.getState().defaultBranch).toBeNull(); + }); + }); + + describe("fetchPullRequests", () => { + it("sets pullRequests on success", async () => { + const mockPrs = [ + { + number: 42, + title: "Add auth", + state: "open", + author: "yamada", + head_branch: "feature/auth", + base_branch: "main", + draft: false, + created_at: "2025-01-01", + updated_at: "2025-01-02", + labels: [], + additions: 100, + deletions: 20, + changed_files: 5, + body: "", + url: "https://github.com/owner/repo/pull/42", + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockPrs); + + await useHostingStore.getState().fetchPullRequests(); + + const state = useHostingStore.getState(); + expect(state.pullRequests).toEqual(mockPrs); + expect(state.loading).toBe(false); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("pr list error")); + + await expect( + useHostingStore.getState().fetchPullRequests(), + ).rejects.toThrow(); + + const state = useHostingStore.getState(); + expect(state.error).toContain("pr list error"); + expect(state.loading).toBe(false); + }); + }); + + describe("fetchIssues", () => { + it("sets issues on success", async () => { + const mockIssues = [ + { + number: 18, + title: "Fix login", + state: "open", + author: "tanaka", + labels: [], + created_at: "2025-01-01", + url: "https://github.com/owner/repo/issues/18", + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockIssues); + + await useHostingStore.getState().fetchIssues(); + + const state = useHostingStore.getState(); + expect(state.issues).toEqual(mockIssues); + expect(state.loading).toBe(false); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("issue list error")); + + await expect(useHostingStore.getState().fetchIssues()).rejects.toThrow(); + + const state = useHostingStore.getState(); + expect(state.error).toContain("issue list error"); + expect(state.loading).toBe(false); + }); + }); + + describe("selectPr", () => { + it("sets selectedPrNumber and selectedPrDetail on success", async () => { + const mockDetail = { + pull_request: { + number: 42, + title: "Add auth", + state: "open", + author: "yamada", + head_branch: "feature/auth", + base_branch: "main", + draft: false, + created_at: "2025-01-01", + updated_at: "2025-01-02", + labels: [], + additions: 100, + deletions: 20, + changed_files: 5, + body: "description", + url: "https://github.com/owner/repo/pull/42", + }, + checks: [], + reviewers: [], + }; + mockedInvoke.mockResolvedValueOnce(mockDetail); + + await useHostingStore.getState().selectPr(42); + + const state = useHostingStore.getState(); + expect(state.selectedPrNumber).toBe(42); + expect(state.selectedPrDetail).toEqual(mockDetail); + expect(state.loading).toBe(false); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("detail error")); + + await expect(useHostingStore.getState().selectPr(42)).rejects.toThrow(); + + const state = useHostingStore.getState(); + expect(state.error).toContain("detail error"); + expect(state.loading).toBe(false); + }); + }); + + describe("setActiveTab", () => { + it("switches to issues tab", () => { + useHostingStore.getState().setActiveTab("issues"); + + expect(useHostingStore.getState().activeTab).toBe("issues"); + }); + + it("switches back to pulls tab", () => { + useHostingStore.getState().setActiveTab("issues"); + useHostingStore.getState().setActiveTab("pulls"); + + expect(useHostingStore.getState().activeTab).toBe("pulls"); + }); + }); + + describe("clearError", () => { + it("clears the error state", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("some error")); + await expect( + useHostingStore.getState().fetchHostingInfo(), + ).rejects.toThrow(); + + expect(useHostingStore.getState().error).not.toBeNull(); + + useHostingStore.getState().clearError(); + expect(useHostingStore.getState().error).toBeNull(); + }); + }); +}); From 7d0cd484e984b7448d250f0b81c3504d77e2a450 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:32:22 +0900 Subject: [PATCH 07/11] =?UTF-8?q?docs(roadmap):=20v0.13=20UI/UX=E3=83=BBGi?= =?UTF-8?q?tHub=E9=80=A3=E6=90=BA=E3=81=AE=E3=82=BF=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E5=AE=8C=E4=BA=86=E7=8A=B6=E6=B3=81=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/roadmap.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index feda7f4..365b99d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -313,17 +313,17 @@ **ゴール**: カスタマイズ性とGitHub/GitLab連携を強化 -- [ ] UI/UX カスタマイズ - - [ ] ライト/ダークモード - - [ ] カラーテーマ選択(Cobalt / Emerald / Rose / Amber / Slate / Violet) - - [ ] カスタムカラーテーマ(設定ファイルで追加可能) - - [ ] フォント設定 - - [ ] キーバインドカスタマイズ - - [ ] レイアウトカスタマイズ -- [ ] GitHub/GitLab 連携 - - [ ] PR/MR 作成(ブラウザで開く) - - [ ] Issue 参照リンク - - [ ] CI/CD 状態表示 +- [x] UI/UX カスタマイズ + - [x] ライト/ダークモード + - [x] カラーテーマ選択(Cobalt / Emerald / Rose / Amber / Slate / Violet) + - [x] カスタムカラーテーマ(設定ファイルで追加可能) + - [x] フォント設定 + - [x] キーバインドカスタマイズ + - [x] レイアウトカスタマイズ +- [x] GitHub/GitLab 連携 + - [x] PR/MR 作成(ブラウザで開く) + - [x] Issue 参照リンク + - [x] CI/CD 状態表示 **完動品としての価値**: 既存のワークフローに統合できる From ad09a22ada40b59e3677990610de49f650083b0c Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:36:04 +0900 Subject: [PATCH 08/11] =?UTF-8?q?style(settings):=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=83=8A=E3=83=93=E3=82=B2=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E9=A0=85=E7=9B=AE=E3=81=AB=E3=83=9C=E3=82=BF=E3=83=B3=E3=83=AA?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=83=88=E3=82=B9=E3=82=BF=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles/settings.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/styles/settings.css b/src/styles/settings.css index 843b309..5e7c641 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -21,6 +21,11 @@ } .settings-nav-item { + background: transparent; + border: none; + font-family: inherit; + font-weight: inherit; + text-align: left; padding: 8px 12px; font-size: 13px; color: var(--text-secondary); From 94abbe43b13611e894717603a2816999cac7391b Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:39:35 +0900 Subject: [PATCH 09/11] fmt --- src-tauri/src/commands/hosting.rs | 19 +++++++------- src-tauri/src/hosting/github.rs | 42 +++++++++++-------------------- src-tauri/src/hosting/types.rs | 5 +--- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/commands/hosting.rs b/src-tauri/src/commands/hosting.rs index 250dc1c..33d08a7 100644 --- a/src-tauri/src/commands/hosting.rs +++ b/src-tauri/src/commands/hosting.rs @@ -6,14 +6,20 @@ use crate::hosting::types::{HostingInfo, Issue, PrDetail, PullRequest}; use crate::state::AppState; fn get_repo_path(state: &State<'_, AppState>) -> Result { - let guard = state.repo.lock().map_err(|e| format!("Lock poisoned: {e}"))?; + let guard = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; let repo = guard.as_ref().ok_or("No repository opened")?; Ok(repo.workdir().to_string_lossy().to_string()) } #[tauri::command] pub fn detect_hosting_provider(state: State<'_, AppState>) -> Result { - let guard = state.repo.lock().map_err(|e| format!("Lock poisoned: {e}"))?; + let guard = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; let repo = guard.as_ref().ok_or("No repository opened")?; let remotes = repo.list_remotes().map_err(|e| e.to_string())?; let remote = remotes.first().ok_or("No remotes found")?; @@ -21,9 +27,7 @@ pub fn detect_hosting_provider(state: State<'_, AppState>) -> Result, -) -> Result, String> { +pub fn list_pull_requests(state: State<'_, AppState>) -> Result, String> { let repo_path = get_repo_path(&state)?; github::list_pull_requests(&repo_path) } @@ -60,10 +64,7 @@ pub fn create_pull_request_url( } #[tauri::command] -pub fn open_in_browser( - state: State<'_, AppState>, - url: String, -) -> Result<(), String> { +pub fn open_in_browser(state: State<'_, AppState>, url: String) -> Result<(), String> { let repo_path = get_repo_path(&state)?; github::open_in_browser(&repo_path, &url) } diff --git a/src-tauri/src/hosting/github.rs b/src-tauri/src/hosting/github.rs index 538060a..b3b49dc 100644 --- a/src-tauri/src/hosting/github.rs +++ b/src-tauri/src/hosting/github.rs @@ -28,10 +28,7 @@ pub fn list_pull_requests(repo_path: &str) -> Result, String> { parse_pr_list_json(&stdout) } -pub fn get_pull_request_detail( - repo_path: &str, - number: u64, -) -> Result { +pub fn get_pull_request_detail(repo_path: &str, number: u64) -> Result { let output = Command::new("gh") .args([ "pr", @@ -78,7 +75,14 @@ pub fn list_issues(repo_path: &str) -> Result, String> { pub fn get_default_branch(repo_path: &str) -> Result { let output = Command::new("gh") - .args(["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]) + .args([ + "repo", + "view", + "--json", + "defaultBranchRef", + "--jq", + ".defaultBranchRef.name", + ]) .current_dir(repo_path) .output() .map_err(|e| format!("failed to execute gh: {e}"))?; @@ -96,11 +100,7 @@ pub fn get_default_branch(repo_path: &str) -> Result { Ok(branch) } -pub fn create_pull_request_url( - repo_path: &str, - head: &str, - base: &str, -) -> Result { +pub fn create_pull_request_url(repo_path: &str, head: &str, base: &str) -> Result { let output = Command::new("gh") .args(["browse", "--no-browser", "-n"]) .current_dir(repo_path) @@ -113,9 +113,7 @@ pub fn create_pull_request_url( } let repo_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(format!( - "{repo_url}/compare/{base}...{head}?expand=1" - )) + Ok(format!("{repo_url}/compare/{base}...{head}?expand=1")) } pub fn open_in_browser(repo_path: &str, url: &str) -> Result<(), String> { @@ -173,17 +171,12 @@ fn parse_issue_list_json(json: &str) -> Result, String> { raw.into_iter() .map(|v| { - let number = v["number"] - .as_u64() - .ok_or("missing field: number")?; + let number = v["number"].as_u64().ok_or("missing field: number")?; let title = v["title"] .as_str() .ok_or("missing field: title")? .to_string(); - let url = v["url"] - .as_str() - .ok_or("missing field: url")? - .to_string(); + let url = v["url"].as_str().ok_or("missing field: url")?.to_string(); Ok(Issue { number, @@ -202,17 +195,12 @@ fn parse_issue_list_json(json: &str) -> Result, String> { } fn map_pr_from_gh_json(v: &serde_json::Value) -> Result { - let number = v["number"] - .as_u64() - .ok_or("missing field: number")?; + let number = v["number"].as_u64().ok_or("missing field: number")?; let title = v["title"] .as_str() .ok_or("missing field: title")? .to_string(); - let url = v["url"] - .as_str() - .ok_or("missing field: url")? - .to_string(); + let url = v["url"].as_str().ok_or("missing field: url")?.to_string(); Ok(PullRequest { number, diff --git a/src-tauri/src/hosting/types.rs b/src-tauri/src/hosting/types.rs index 80184b9..a128c2b 100644 --- a/src-tauri/src/hosting/types.rs +++ b/src-tauri/src/hosting/types.rs @@ -120,10 +120,7 @@ mod tests { #[test] fn pr_state_serializes_as_lowercase() { - assert_eq!( - serde_json::to_string(&PrState::Open).unwrap(), - "\"open\"" - ); + assert_eq!(serde_json::to_string(&PrState::Open).unwrap(), "\"open\""); assert_eq!( serde_json::to_string(&PrState::Merged).unwrap(), "\"merged\"" From 7b0ba6cf2469e20224e2741f074209c79aeda26e Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:48:33 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(hosting):=20open=5Fin=5Fbrowser?= =?UTF-8?q?=E3=82=92tauri-plugin-opener=E3=81=AB=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std::process::Commandによるプラットフォーム別分岐(open/xdg-open/cmd)を tauri-plugin-openerに置き換え。dev modeでもブラウザ起動が動作するようになる。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/Cargo.lock | 455 ++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/hosting.rs | 8 +- src-tauri/src/hosting/github.rs | 27 -- src-tauri/src/lib.rs | 1 + 6 files changed, 464 insertions(+), 31 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81a3458..dc34b0a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,6 +89,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-log", + "tauri-plugin-opener", "tempfile", "thiserror 2.0.18", "toml 0.8.2", @@ -101,6 +102,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "atk" version = "0.18.2" @@ -193,6 +325,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.0" @@ -438,6 +583,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -775,6 +929,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -818,6 +999,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -984,6 +1186,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1387,6 +1602,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1713,6 +1934,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2429,6 +2669,18 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -2453,6 +2705,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2478,6 +2740,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2501,6 +2769,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2653,6 +2927,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2685,6 +2970,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3399,6 +3698,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3812,6 +4121,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.10.0" @@ -4210,9 +4541,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4262,6 +4605,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5337,6 +5691,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.14", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.116", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.14", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -5416,3 +5831,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.14", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.116", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.116", + "winnow 0.7.14", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6791831..3f075d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = ["macos-private-api"] } tauri-plugin-log = "2" +tauri-plugin-opener = "2" git2 = "0.20" thiserror = "2" dirs = "6" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 605ef65..5167782 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-close", "core:window:allow-minimize", "core:window:allow-toggle-maximize", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "opener:default" ] } diff --git a/src-tauri/src/commands/hosting.rs b/src-tauri/src/commands/hosting.rs index 33d08a7..635dea6 100644 --- a/src-tauri/src/commands/hosting.rs +++ b/src-tauri/src/commands/hosting.rs @@ -1,4 +1,5 @@ use tauri::State; +use tauri_plugin_opener::OpenerExt; use crate::hosting::detector; use crate::hosting::github; @@ -64,7 +65,8 @@ pub fn create_pull_request_url( } #[tauri::command] -pub fn open_in_browser(state: State<'_, AppState>, url: String) -> Result<(), String> { - let repo_path = get_repo_path(&state)?; - github::open_in_browser(&repo_path, &url) +pub fn open_in_browser(app: tauri::AppHandle, url: String) -> Result<(), String> { + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| format!("failed to open browser: {e}")) } diff --git a/src-tauri/src/hosting/github.rs b/src-tauri/src/hosting/github.rs index b3b49dc..0f91d41 100644 --- a/src-tauri/src/hosting/github.rs +++ b/src-tauri/src/hosting/github.rs @@ -116,33 +116,6 @@ pub fn create_pull_request_url(repo_path: &str, head: &str, base: &str) -> Resul Ok(format!("{repo_url}/compare/{base}...{head}?expand=1")) } -pub fn open_in_browser(repo_path: &str, url: &str) -> Result<(), String> { - #[cfg(target_os = "macos")] - let cmd = "open"; - #[cfg(target_os = "linux")] - let cmd = "xdg-open"; - #[cfg(target_os = "windows")] - let cmd = "cmd"; - - #[cfg(target_os = "windows")] - let args = vec!["/C", "start", "", url]; - #[cfg(not(target_os = "windows"))] - let args = vec![url]; - - let output = Command::new(cmd) - .args(&args) - .current_dir(repo_path) - .output() - .map_err(|e| format!("failed to open browser: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("open failed: {stderr}")); - } - - Ok(()) -} - fn parse_pr_list_json(json: &str) -> Result, String> { let raw: Vec = serde_json::from_str(json).map_err(|e| format!("failed to parse JSON: {e}"))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5513297..fabdf0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,6 +55,7 @@ pub fn run() { }); tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) .manage(AppState { repo: Mutex::new(repo), watcher: Mutex::new(None), From 7e792a6d60b1197aaeb1a93589fa377ded59095b Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 14:59:47 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(hosting):=20SSH=E3=83=AA=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=88URL=E3=81=8B=E3=82=89HTTPS=20URL=E3=82=92?= =?UTF-8?q?=E6=A7=8B=E7=AF=89=E3=81=97=E3=81=A6=E3=83=96=E3=83=A9=E3=82=A6?= =?UTF-8?q?=E3=82=B6=E3=81=A7=E9=96=8B=E3=81=91=E3=82=8B=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + src-tauri/src/hosting/detector.rs | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3cde541..b1b6fad 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ result # Frontend node_modules/ dist/ +.playwright-mcp # Rust / Tauri src-tauri/target/ diff --git a/src-tauri/src/hosting/detector.rs b/src-tauri/src/hosting/detector.rs index bf44df3..db6a656 100644 --- a/src-tauri/src/hosting/detector.rs +++ b/src-tauri/src/hosting/detector.rs @@ -65,15 +65,27 @@ fn parse_gitlab(url: &str) -> Option { fn parse_owner_repo( path: &str, provider: HostingProviderKind, - original_url: &str, + _original_url: &str, ) -> Option { let parts: Vec<&str> = path.splitn(2, '/').collect(); if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { + let owner = parts[0]; + let repo = parts[1]; + let base = match provider { + HostingProviderKind::Github => "https://github.com", + HostingProviderKind::Gitlab => "https://gitlab.com", + HostingProviderKind::Unknown => "", + }; + let url = if base.is_empty() { + _original_url.to_string() + } else { + format!("{base}/{owner}/{repo}") + }; Some(HostingInfo { provider, - owner: parts[0].to_string(), - repo: parts[1].to_string(), - url: original_url.to_string(), + owner: owner.to_string(), + repo: repo.to_string(), + url, }) } else { None @@ -90,6 +102,7 @@ mod tests { assert_eq!(info.provider, HostingProviderKind::Github); assert_eq!(info.owner, "HMasataka"); assert_eq!(info.repo, "rocket"); + assert_eq!(info.url, "https://github.com/HMasataka/rocket"); } #[test] @@ -98,6 +111,7 @@ mod tests { assert_eq!(info.provider, HostingProviderKind::Github); assert_eq!(info.owner, "HMasataka"); assert_eq!(info.repo, "rocket"); + assert_eq!(info.url, "https://github.com/HMasataka/rocket"); } #[test] @@ -106,6 +120,7 @@ mod tests { assert_eq!(info.provider, HostingProviderKind::Github); assert_eq!(info.owner, "owner"); assert_eq!(info.repo, "repo"); + assert_eq!(info.url, "https://github.com/owner/repo"); } #[test] @@ -114,6 +129,7 @@ mod tests { assert_eq!(info.provider, HostingProviderKind::Gitlab); assert_eq!(info.owner, "owner"); assert_eq!(info.repo, "repo"); + assert_eq!(info.url, "https://gitlab.com/owner/repo"); } #[test] @@ -122,6 +138,7 @@ mod tests { assert_eq!(info.provider, HostingProviderKind::Gitlab); assert_eq!(info.owner, "owner"); assert_eq!(info.repo, "repo"); + assert_eq!(info.url, "https://gitlab.com/owner/repo"); } #[test]