diff --git a/css/index.css b/css/index.css index f54b07c..14514da 100644 --- a/css/index.css +++ b/css/index.css @@ -165,3 +165,175 @@ text-shadow: -1px 1px #417cb8; border: none; } + +/* URL入力とClaude Codeボタンの行 */ +.URLInputRow { + display: flex; + align-items: center; + gap: 8px; + margin: 0.1em 0; +} + +.URLInputRow .URLInput { + flex: 1; + margin: 0; +} + +/* Claude Codeボタン */ +.ClaudeCodeButton { + padding: 6px 8px; + font-size: 12px; + font-weight: bold; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + color: #666; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + min-width: 36px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} + +.ClaudeCodeButton svg { + vertical-align: middle; +} + +.ClaudeCodeButton:hover:not(:disabled) { + background-color: #e0e0e0; + border-color: #999; +} + +.ClaudeCodeButton:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.ClaudeCodeButton--loading { + background-color: #fff3cd; + border-color: #ffc107; + color: #856404; +} + +.ClaudeCodeButton--complete { + background-color: #d4edda; + border-color: #28a745; + color: #155724; +} + +.ClaudeCodeButton--complete:hover:not(:disabled) { + background-color: #c3e6cb; + border-color: #1e7e34; +} + +.ClaudeCodeButton--error { + background-color: #f8d7da; + border-color: #dc3545; + color: #721c24; +} + +.ClaudeCodeButton-loading { + display: flex; + align-items: center; + gap: 4px; +} + +.ClaudeCodeButton-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #856404; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Claude Code Preview */ +.ClaudeCodePreview { + margin: 0.5em 0; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; +} + +.ClaudeCodePreview-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-weight: bold; + font-size: 12px; +} + +.ClaudeCodePreview-hint { + font-weight: normal; + color: #666; + font-size: 11px; +} + +.ClaudeCodePreview-close { + margin-left: auto; + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: #999; + padding: 0 4px; +} + +.ClaudeCodePreview-close:hover { + color: #333; +} + +.ClaudeCodePreview-content { + white-space: pre-wrap; + line-height: 1.5; + font-family: Monaco, "Andale Mono", monospace; +} + +.ClaudeCodePreview--loading { + background-color: #fff8e1; + border: 1px solid #ffcc02; +} + +.ClaudeCodePreview--loading .ClaudeCodePreview-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #f0a000; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.ClaudeCodePreview--complete { + background-color: #e8f5e9; + border: 1px solid #4caf50; +} + +.ClaudeCodePreview--complete .ClaudeCodePreview-content { + cursor: pointer; +} + +.ClaudeCodePreview--complete .ClaudeCodePreview-content:hover { + background-color: rgba(76, 175, 80, 0.1); +} + +.ClaudeCodePreview--error { + background-color: #ffebee; + border: 1px solid #f44336; +} + +.ClaudeCodePreview--error .ClaudeCodePreview-content { + color: #c62828; +} diff --git a/src/browser/Action/ServiceAction.js b/src/browser/Action/ServiceAction.js index 9ec2328..da625d0 100644 --- a/src/browser/Action/ServiceAction.js +++ b/src/browser/Action/ServiceAction.js @@ -8,6 +8,8 @@ import notie from "notie"; import { show as LoadingShow, dismiss as LoadingDismiss } from "../view-util/Loading"; import RelatedItemModel from "../models/RelatedItemModel"; import serviceInstance from "../service-instance"; +import { spawn } from "child_process"; +import fs from "fs"; export default class ServiceAction extends Action { fetchTags(service) { @@ -191,4 +193,60 @@ export default class ServiceAction extends Action { resetField() { this.dispatch(keys.resetField); } + + // Claude Code関連アクション + runClaudeCode(url, config) { + if (!config?.enabled) return; + if (!fs.existsSync(config.cliPath)) return; + if (!fs.existsSync(config.workDir)) { + this.dispatch(keys.claudeCodeError, { url, error: `WorkDir not found: ${config.workDir}` }); + return; + } + + this.dispatch(keys.claudeCodeStart, { url }); + + const args = []; + if (config.mcpConfig) { + args.push("--mcp-config", JSON.stringify(config.mcpConfig)); + } + args.push("--print", "--dangerously-skip-permissions", `${config.prompt}\n\nURL: ${url}`); + + const claudeProcess = spawn(config.cliPath, args, { + cwd: config.workDir, + env: { + ...process.env, + PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + process.env.PATH + }, + shell: false, + stdio: ["ignore", "pipe", "pipe"] + }); + + let stdout = ""; + let stderr = ""; + + claudeProcess.stdout.on("data", (data) => (stdout += data.toString())); + claudeProcess.stderr.on("data", (data) => (stderr += data.toString())); + + claudeProcess.on("close", (code) => { + if (code === 0 && stdout) { + const match = stdout.match(/```(?:markdown)?\s*([\s\S]*?)```/); + const result = match ? match[1].trim() : stdout.trim(); + this.dispatch(keys.claudeCodeComplete, { url, result }); + } else { + this.dispatch(keys.claudeCodeError, { url, error: stderr || `Exit code: ${code}` }); + } + }); + + claudeProcess.on("error", (error) => { + this.dispatch(keys.claudeCodeError, { url, error: error.message }); + }); + } + + clearClaudeCodeResult() { + this.dispatch(keys.claudeCodeClear); + } + + insertClaudeCodeResult() { + this.dispatch(keys.claudeCodeInsert); + } } diff --git a/src/browser/Action/ServiceActionConst.js b/src/browser/Action/ServiceActionConst.js index 0495166..4afa691 100644 --- a/src/browser/Action/ServiceActionConst.js +++ b/src/browser/Action/ServiceActionConst.js @@ -14,5 +14,11 @@ export default { removeRelatedItem: Symbol("removeRelatedItem"), finishEditingRelatedItem: Symbol("finishEditingRelatedItem"), enableService: Symbol("enableService"), - disableService: Symbol("disableService") + disableService: Symbol("disableService"), + // Claude Code関連 + claudeCodeStart: Symbol("claudeCodeStart"), + claudeCodeComplete: Symbol("claudeCodeComplete"), + claudeCodeError: Symbol("claudeCodeError"), + claudeCodeClear: Symbol("claudeCodeClear"), + claudeCodeInsert: Symbol("claudeCodeInsert") }; diff --git a/src/browser/App.js b/src/browser/App.js index bcc3b39..d17d140 100644 --- a/src/browser/App.js +++ b/src/browser/App.js @@ -10,8 +10,10 @@ import TitleInput from "./component/TitleInput"; import SubmitButton from "./component/SubmitButton"; import RelatedListBox from "./component/RelatedListBox"; import ServiceList from "./component/ServiceList"; +import ClaudeCodeButton from "./component/ClaudeCodeButton"; +import ClaudeCodePreview from "./component/ClaudeCodePreview"; import AppContext from "./AppContext"; -import serviceManger, { waitForInitialization } from "./service-instance"; +import serviceManger, { waitForInitialization, getClaudeCodeConfig } from "./service-instance"; const ipcRenderer = require("electron").ipcRenderer; const appContext = new AppContext(); @@ -20,6 +22,7 @@ class App extends React.Component { constructor(...args) { super(...args); this._TagSelect = null; + this._claudeCodeConfig = getClaudeCodeConfig(); this.state = Object.assign( { initialized: false @@ -70,7 +73,9 @@ class App extends React.Component { const service = serviceManger.getTagService(); if (service && state.selectedTags.length === 0 && state.comment.length === 0) { appContext.ServiceAction.fetchContent(service, URL) - .then(({ comment, tags, relatedItems }) => { + .then((result) => { + if (!result) return; + const { comment, tags, relatedItems } = result; if (comment) { appContext.ServiceAction.updateComment(comment); } @@ -187,6 +192,18 @@ class App extends React.Component { ServiceAction.addRelatedItem(); }; const submitPostLink = this.postLink.bind(this); + + // Claude Code関連 + const runClaudeCode = (url, config) => { + ServiceAction.runClaudeCode(url, config); + }; + const insertClaudeCodeResult = () => { + ServiceAction.insertClaudeCodeResult(); + }; + const clearClaudeCodeResult = () => { + ServiceAction.clearClaudeCodeResult(); + }; + return (
- +
+ + +
(this._TagSelect = c)} @@ -205,12 +232,18 @@ class App extends React.Component { selectTags={selectTags} selectedTags={this.state.selectedTags} /> + { if (tags.length > 0) { @@ -75,7 +82,13 @@ export default class ServiceStore extends Store { quote: "", selectedTags: [], relatedItems: [], - enabledServiceIDs: checkedServicesByDefault + enabledServiceIDs: checkedServicesByDefault, + claudeCode: { + status: "idle", + url: null, + result: null, + error: null + } }); }; this.register(keys.resetField, resetState); @@ -117,5 +130,66 @@ export default class ServiceStore extends Store { }; this.register(keys.editRelatedItem, updateRelatedItem); this.register(keys.finishEditingRelatedItem, updateRelatedItem); + + // Claude Code関連 + this.register(keys.claudeCodeStart, ({ url }) => { + this.setState({ + claudeCode: { + status: "loading", + url, + result: null, + error: null + } + }); + }); + + this.register(keys.claudeCodeComplete, ({ url, result }) => { + this.setState({ + claudeCode: { + status: "complete", + url, + result, + error: null + } + }); + }); + + this.register(keys.claudeCodeError, ({ url, error }) => { + this.setState({ + claudeCode: { + status: "error", + url, + result: null, + error + } + }); + }); + + this.register(keys.claudeCodeClear, () => { + this.setState({ + claudeCode: { + status: "idle", + url: null, + result: null, + error: null + } + }); + }); + + this.register(keys.claudeCodeInsert, () => { + const { claudeCode } = this.state; + if (claudeCode.result) { + // 結果でコメントを入れ替え + this.setState({ + comment: claudeCode.result, + claudeCode: { + status: "idle", + url: null, + result: null, + error: null + } + }); + } + }); } } diff --git a/src/browser/component/ClaudeCodeButton.js b/src/browser/component/ClaudeCodeButton.js new file mode 100644 index 0000000..edae117 --- /dev/null +++ b/src/browser/component/ClaudeCodeButton.js @@ -0,0 +1,104 @@ +// LICENSE : MIT +"use strict"; +import React, { useEffect, useRef, useCallback } from "react"; + +export default function ClaudeCodeButton({ + url, + claudeCode, + runClaudeCode, + insertResult, + clearResult, + claudeCodeConfig +}) { + const prevUrlRef = useRef(url); + const debounceTimerRef = useRef(null); + + // URLが変更されたら自動でClaude Codeを実行(デバウンス付き) + useEffect(() => { + if (!claudeCodeConfig?.enabled) { + return; + } + + // URLが変更され、有効なURLの場合のみ実行 + if (url && url !== prevUrlRef.current && url.startsWith("http")) { + // 前回のタイマーをクリア + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // 1秒後に実行(入力中の連続変更を避ける) + debounceTimerRef.current = setTimeout(() => { + runClaudeCode(url, claudeCodeConfig); + }, 1000); + } + + prevUrlRef.current = url; + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [url, claudeCodeConfig, runClaudeCode]); + + const handleClick = useCallback(() => { + if (claudeCode.status === "complete") { + // 結果がある場合は挿入 + insertResult(); + } else if (claudeCode.status === "idle" || claudeCode.status === "error") { + // アイドルまたはエラー状態の場合は実行 + if (url && url.startsWith("http")) { + runClaudeCode(url, claudeCodeConfig); + } + } + }, [claudeCode.status, url, claudeCodeConfig, runClaudeCode, insertResult]); + + // 設定が無効またはCLIが設定されていない場合は表示しない + if (!claudeCodeConfig?.enabled) { + return null; + } + + const getButtonContent = () => { + switch (claudeCode.status) { + case "loading": + return ( + + + AI + + ); + case "complete": + return ( + + AI ✓ + + ); + case "error": + return ( + + AI ! + + ); + default: + return AI; + } + }; + + return ( + + ); +} diff --git a/src/browser/component/ClaudeCodePreview.js b/src/browser/component/ClaudeCodePreview.js new file mode 100644 index 0000000..75eb1f3 --- /dev/null +++ b/src/browser/component/ClaudeCodePreview.js @@ -0,0 +1,42 @@ +// LICENSE : MIT +"use strict"; +import React from "react"; + +export default function ClaudeCodePreview({ claudeCode, insertResult, clearResult }) { + if (claudeCode.status === "idle" || claudeCode.status === "loading") { + return null; + } + + if (claudeCode.status === "error") { + return ( +
+
+ エラー + +
+
{claudeCode.error}
+
+ ); + } + + if (claudeCode.status === "complete" && claudeCode.result) { + return ( +
+
+ AI生成結果 + Cmd+Shift+J で挿入 + +
+
+ {claudeCode.result} +
+
+ ); + } + + return null; +} diff --git a/src/browser/component/Editor.js b/src/browser/component/Editor.js index 75a050b..0bdf6cb 100644 --- a/src/browser/component/Editor.js +++ b/src/browser/component/Editor.js @@ -58,7 +58,7 @@ const textlintLinter = createTextlintLinter(); const Combokeys = require("combokeys"); -export default function Editor({ value, onChange, onSubmit, services, toggleServiceAtIndex }) { +export default function Editor({ value, onChange, onSubmit, services, toggleServiceAtIndex, onInsertClaudeCode }) { const combokeysRef = useRef(null); useEffect(() => { @@ -78,12 +78,19 @@ export default function Editor({ value, onChange, onSubmit, services, toggleServ toggleServiceAtIndex(services.length - 1); }); + // Cmd+Shift+J でClaude Codeの結果を挿入 + combokeysRef.current.bindGlobal(`command+shift+j`, () => { + if (onInsertClaudeCode) { + onInsertClaudeCode(); + } + }); + return () => { if (combokeysRef.current) { combokeysRef.current.detach(); } }; - }, [services.length, toggleServiceAtIndex, onSubmit]); // Ensure toggleServiceAtIndex and onSubmit are memoized in the parent component + }, [services.length, toggleServiceAtIndex, onSubmit, onInsertClaudeCode]); // Ensure toggleServiceAtIndex and onSubmit are memoized in the parent component // 最小高さを設定するテーマ const minHeightTheme = EditorView.theme({ diff --git a/src/browser/service-instance.js b/src/browser/service-instance.js index 31917d8..9545959 100644 --- a/src/browser/service-instance.js +++ b/src/browser/service-instance.js @@ -75,5 +75,19 @@ export async function waitForInitialization() { return initializeManager(); } +// Claude Code設定を取得 +export function getClaudeCodeConfig() { + try { + if (process.env.PLAYWRIGHT_TEST === "1" || process.title?.includes("playwright")) { + return { enabled: false }; + } + const serviceModule = notBundledRequire("../service.js"); + return serviceModule.claudeCodeConfig || { enabled: false }; + } catch (error) { + console.error("Failed to load Claude Code config:", error); + return { enabled: false }; + } +} + // デフォルトエクスポートはmanagerのままだが、使用前に初期化が必要 export default manager;