diff --git a/.changeset/spicy-webs-cover.md b/.changeset/spicy-webs-cover.md new file mode 100644 index 0000000..6fcbe1b --- /dev/null +++ b/.changeset/spicy-webs-cover.md @@ -0,0 +1,5 @@ +--- +"commitcrawler": patch +--- + +우클릭 기반의 동작 기능 추가 diff --git a/public/manifest.json b/public/manifest.json index 8295eea..af655bd 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -3,7 +3,7 @@ "name": "CommitExtractor", "version": "1.0.3", "description": "A tool for extracting commit messages and generating PR templates with custom regex patterns.", - "permissions": ["debugger", "tabs", "storage"], + "permissions": ["debugger", "tabs", "storage", "contextMenus"], "host_permissions": [ "https://*.gitlab.com/*", "https://*.gabia.io/*", diff --git a/src/commitInterceptor.ts b/src/commitInterceptor.ts index 51fae58..ea5ea61 100644 --- a/src/commitInterceptor.ts +++ b/src/commitInterceptor.ts @@ -8,6 +8,7 @@ import { import { GitlabCommitParser } from "./services/git/parser/GitlabCommitParser"; import { CommitMessageFormatter } from "./services/git/parser/CommitMessageFormatter"; import { GIT_SERVICE_INFO } from "./services/git/types"; +import { initializeContextMenu } from "./contextMenuHandler"; const debuggerService = new DebuggerService(); const commitInterceptorService = new CommitInterceptorService( @@ -15,11 +16,14 @@ const commitInterceptorService = new CommitInterceptorService( debuggerService ); +initializeContextMenu(); + chrome.runtime.onMessage.addListener( (message: MessagePayload<{ targetTabId: number }>, sender, sendResponse) => { if (message.action === "START_INTERCEPTOR_COMMIT") { const targetTabId = message.data?.targetTabId; if (!targetTabId) { + console.error("Target tab ID missing for START_INTERCEPTOR_COMMIT"); sendResponse({ status: "error", message: "탭 ID가 없습니다." }); return true; } @@ -32,7 +36,6 @@ chrome.runtime.onMessage.addListener( } ); -// 네트워크 응답 처리 chrome.debugger.onEvent.addListener((source, method, params) => { if (method === "Network.responseReceived") { handleNetworkResponse(source, params); @@ -42,10 +45,6 @@ chrome.debugger.onEvent.addListener((source, method, params) => { async function handleNetworkResponse(source, params) { if (!commitInterceptorService.isTaskExecutionActive()) return; - // FIXME 의존성 분리 필요(이유는 모르겠으나 에러도 안찍힘) - // const siteInfo = getGitInfo(); - // const { parser } = siteInfo; - if (params.response.url.includes(GIT_SERVICE_INFO.gitlab.apiEndpoint)) { try { const response = await debuggerService.getResponseBody( @@ -69,7 +68,10 @@ async function handleNetworkResponse(source, params) { "INTERCEPTOR_COMMIT_FAILED", "커밋 데이터 파싱 실패" ); + commitInterceptorService.detachDebugger(source.tabId); } + } else { + console.warn("Response body was empty for request:", params.requestId); } } catch (error) { console.error("Response body error:", error); @@ -77,6 +79,7 @@ async function handleNetworkResponse(source, params) { "INTERCEPTOR_COMMIT_FAILED", "응답 데이터 가져오기 실패" ); + commitInterceptorService.detachDebugger(source.tabId); } } } diff --git a/src/contextMenuHandler.ts b/src/contextMenuHandler.ts new file mode 100644 index 0000000..55a469c --- /dev/null +++ b/src/contextMenuHandler.ts @@ -0,0 +1,51 @@ +const CONTEXT_MENU_ID = "openCommitExtractorPopup"; + +function createContextMenu() { + chrome.contextMenus.create({ + id: CONTEXT_MENU_ID, + title: "CommitExtractor 열기", + contexts: ["page"], + }); +} + +function handleContextMenuClick( + info: chrome.contextMenus.OnClickData, + tab?: chrome.tabs.Tab +) { + if (info.menuItemId === CONTEXT_MENU_ID) { + const originalTabId = tab?.id; + const originalUrl = tab?.url; + + if (!originalTabId || !originalUrl) { + console.error( + "Could not get the original tab ID or URL for context menu action." + ); + return; + } + + const encodedUrl = encodeURIComponent(originalUrl); + const popupUrl = chrome.runtime.getURL( + `base.html?originalTabId=${originalTabId}&originalUrl=${encodedUrl}` + ); + + const windowWidth = 800; + const windowHeight = 600; + + chrome.windows.create({ + url: popupUrl, + type: "popup", + width: windowWidth, + height: windowHeight, + }); + } +} + +export function initializeContextMenu(): void { + chrome.contextMenus.removeAll(() => { + createContextMenu(); + }); + + chrome.runtime.onInstalled.addListener(createContextMenu); + + chrome.contextMenus.onClicked.addListener(handleContextMenuClick); +} diff --git a/src/popup.ts b/src/popup.ts index 9f6aa9e..c580366 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -4,6 +4,11 @@ import { MessageDispatcher, MessagePayload, } from "./services/MessageDispatcher"; +import { + loadPopupState, + savePopupState, + PopupState, +} from "./services/popupStorageService"; interface StatusElements { button: HTMLButtonElement; @@ -21,17 +26,6 @@ interface StatusElements { resetCleanRegexButton: HTMLButtonElement; } -interface StoredData { - messages: { - key: string; - text: string; - checked: boolean; - }[]; - summary: string; - summaryChecked: boolean; - ticketRegex: string; -} - const getStatusElements = (): StatusElements | null => { const startButton = document.getElementById( "startButton" @@ -101,22 +95,75 @@ const getStatusElements = (): StatusElements | null => { }; }; +const getCurrentStateFromDOM = (elements: StatusElements): PopupState => { + const { + messageList, + status, + summaryCheckbox, + ticketRegexInput, + cleanRegexInput, + } = elements; + const messages = Array.from( + messageList.querySelectorAll(".message-item") + ).map((item) => { + const checkbox = item.querySelector( + 'input[type="checkbox"]' + ) as HTMLInputElement; + const keyInput = item.querySelector(".key-input") as HTMLInputElement; + const messageText = item.querySelector(".message-text") as HTMLSpanElement; + return { + key: keyInput.value.trim(), + text: messageText.textContent || "", + checked: checkbox.checked, + }; + }); + + return { + messages, + summary: status.textContent || "", + summaryChecked: summaryCheckbox.checked, + ticketRegex: ticketRegexInput.value, + cleanRegex: cleanRegexInput.value, + }; +}; + const setupEventListeners = (elements: StatusElements): void => { const { button, previewContent } = elements; - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - const currentUrl = tabs[0]?.url || ""; - const isValidPath = new RegExp(GIT_SERVICE_INFO.gitlab.domain).test( - currentUrl - ); + const urlParams = new URLSearchParams(window.location.search); + const originalUrlParam = urlParams.get("originalUrl"); - updateUIForValidPath(elements, isValidPath); - }); + if (originalUrlParam) { + try { + const decodedUrl = decodeURIComponent(originalUrlParam); + const isValidPath = new RegExp(GIT_SERVICE_INFO.gitlab.domain).test( + decodedUrl + ); + updateUIForValidPath(elements, isValidPath, decodedUrl); + } catch (e) { + console.error("Failed to decode URL parameter:", e); + updateUIForValidPath(elements, false, "Invalid URL parameter"); + } + } else { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const currentUrl = tabs[0]?.url || ""; + const isValidPath = new RegExp(GIT_SERVICE_INFO.gitlab.domain).test( + currentUrl + ); + updateUIForValidPath(elements, isValidPath, currentUrl); + }); + } const { copyButton, messageList: messageListFromElements, summaryCheckbox, + ticketRegexInput, + resetRegexButton, + toggleRegexButton, + regexInputContainer, + cleanRegexInput, + resetCleanRegexButton, } = elements; button.addEventListener("click", () => { @@ -143,17 +190,29 @@ const setupEventListeners = (elements: StatusElements): void => { if (e.target instanceof HTMLInputElement && e.target.type === "checkbox") { updateSummary(elements); updatePreview(elements); - saveToStorage(elements); + savePopupState(getCurrentStateFromDOM(elements)); + } + }); + + messageListFromElements.addEventListener("input", (e) => { + if ( + e.target instanceof HTMLInputElement && + e.target.classList.contains("key-input") + ) { + updateSummary(elements); + updatePreview(elements); + savePopupState(getCurrentStateFromDOM(elements)); } }); summaryCheckbox.addEventListener("change", () => { updatePreview(elements); - saveToStorage(elements); + savePopupState(getCurrentStateFromDOM(elements)); }); chrome.runtime.onMessage.addListener((message: MessagePayload) => { handleClipboardCopy(message, elements); + savePopupState(getCurrentStateFromDOM(elements)); }); messageListFromElements.addEventListener("dragstart", (e) => { @@ -183,7 +242,7 @@ const setupEventListeners = (elements: StatusElements): void => { updateSummary(elements); updatePreview(elements); - saveToStorage(elements); + savePopupState(getCurrentStateFromDOM(elements)); } }); @@ -215,67 +274,65 @@ const setupEventListeners = (elements: StatusElements): void => { } }); - const { ticketRegexInput, resetRegexButton } = elements; - ticketRegexInput.addEventListener("change", () => { const pattern = ticketRegexInput.value.replace(/^\/|\/$/g, ""); try { new RegExp(pattern); CommitMessageFormatter.setTicketRegex(ticketRegexInput.value); - saveToStorage(elements); + savePopupState(getCurrentStateFromDOM(elements)); + showToast(elements, "정규식이 업데이트되었습니다.", "success"); updateSummary(elements); updatePreview(elements); - showToast(elements, "정규식이 업데이트되었습니다.", "success"); } catch (error) { showToast(elements, "유효하지 않은 정규식입니다.", "error"); - ticketRegexInput.value = CommitMessageFormatter.getDefaultTicketPattern(); + ticketRegexInput.value = `/${CommitMessageFormatter.getDefaultTicketPattern()}/`; + CommitMessageFormatter.setTicketRegex(ticketRegexInput.value); + savePopupState(getCurrentStateFromDOM(elements)); } }); resetRegexButton.addEventListener("click", () => { ticketRegexInput.value = `/${CommitMessageFormatter.getDefaultTicketPattern()}/`; CommitMessageFormatter.setTicketRegex(ticketRegexInput.value); - saveToStorage(elements); updateSummary(elements); updatePreview(elements); + savePopupState(getCurrentStateFromDOM(elements)); showToast(elements, "기본 정규식으로 초기화되었습니다.", "success"); }); - const { toggleRegexButton, regexInputContainer } = elements; - toggleRegexButton.addEventListener("click", () => { const toggleIcon = toggleRegexButton.querySelector(".toggle-icon"); regexInputContainer.classList.toggle("hidden"); toggleIcon?.classList.toggle("open"); }); - const { cleanRegexInput, resetCleanRegexButton } = elements; - cleanRegexInput.addEventListener("change", () => { const pattern = cleanRegexInput.value.replace(/^\/|\/$/g, ""); try { new RegExp(pattern); CommitMessageFormatter.setCleanRegex(cleanRegexInput.value); - saveToStorage(elements); - updateSummary(elements); - updatePreview(elements); + savePopupState(getCurrentStateFromDOM(elements)); showToast( elements, "접두사 제거 정규식이 업데이트되었습니다.", "success" ); + updateSummary(elements); + updatePreview(elements); } catch (error) { showToast(elements, "유효하지 않은 정규식입니다.", "error"); cleanRegexInput.value = CommitMessageFormatter.getDefaultCleanPattern(); + CommitMessageFormatter.setCleanRegex(cleanRegexInput.value); + savePopupState(getCurrentStateFromDOM(elements)); } }); resetCleanRegexButton.addEventListener("click", () => { cleanRegexInput.value = CommitMessageFormatter.getDefaultCleanPattern(); CommitMessageFormatter.setCleanRegex(cleanRegexInput.value); - saveToStorage(elements); updateSummary(elements); updatePreview(elements); + savePopupState(getCurrentStateFromDOM(elements)); showToast( elements, "기본 접두사 제거 정규식으로 초기화되었습니다.", @@ -286,13 +343,14 @@ const setupEventListeners = (elements: StatusElements): void => { const updateUIForValidPath = ( elements: StatusElements, - isValid: boolean + isValid: boolean, + url: string ): void => { const { button, previewContent } = elements; if (!isValid) { button.disabled = true; - button.innerHTML = `Commit 메세지 불러오기
유효한 페이지에서 사용 가능합니다.
${GIT_SERVICE_INFO.gitlab.urlGuidanceMessage}
`; + button.innerHTML = `Commit 메세지 불러오기
유효한 페이지(${GIT_SERVICE_INFO.gitlab.urlGuidanceMessage})에서 사용 가능합니다.`; previewContent.textContent = "Merge Request 페이지에서 실행해주세요"; } else { button.disabled = false; @@ -327,17 +385,34 @@ const handleStartInterceptorCommit = async ( button.disabled = true; try { - const [tab] = await chrome.tabs.query({ - active: true, - currentWindow: true, - }); - if (!tab?.id) { - throw new Error("활성 탭을 찾을 수 없습니다."); + let targetTabId: number | undefined; + + // 현재 URL에서 originalTabId 파라미터를 확인 + const urlParams = new URLSearchParams(window.location.search); + const originalTabIdParam = urlParams.get("originalTabId"); + + const openByContextMenu = originalTabIdParam !== null; + + if (openByContextMenu) { + targetTabId = parseInt(originalTabIdParam, 10); + if (isNaN(targetTabId)) { + throw new Error("Invalid originalTabId parameter."); + } + } else { + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + targetTabId = tab?.id; + } + + if (!targetTabId) { + throw new Error("유효한 대상 탭 ID를 찾을 수 없습니다."); } const response = await MessageDispatcher.sendSuccess( "START_INTERCEPTOR_COMMIT", - { targetTabId: tab.id } + { targetTabId: targetTabId } ); if (response.status === "success") { @@ -386,7 +461,6 @@ const updateSummary = (elements: StatusElements): void => { status.textContent = summary; toggleSummaryCheckbox(elements); - saveToStorage(elements); }; const updatePreview = (elements: StatusElements): void => { @@ -425,34 +499,6 @@ const updatePreview = (elements: StatusElements): void => { previewContent.textContent = previewText; }; -const saveToStorage = async (elements: StatusElements): Promise => { - const { messageList, status, summaryCheckbox } = elements; - - const messages = Array.from( - messageList.querySelectorAll(".message-item") - ).map((item) => { - const checkbox = item.querySelector( - 'input[type="checkbox"]' - ) as HTMLInputElement; - const keyInput = item.querySelector(".key-input") as HTMLInputElement; - const messageText = item.querySelector(".message-text") as HTMLSpanElement; - - return { - key: keyInput.value.trim(), - text: messageText.textContent || "", - checked: checkbox.checked, - }; - }); - - await chrome.storage.local.set({ - storedData: { - messages, - summary: status.textContent, - summaryChecked: summaryCheckbox.checked, - }, - }); -}; - const createMessageItem = ( msg: { key: string; text: string; checked: boolean }, index: number @@ -475,67 +521,6 @@ const createMessageItem = ( return div; }; -const loadFromStorage = async (elements: StatusElements): Promise => { - const { messageList, status, copyButton, summaryCheckbox, ticketRegexInput } = - elements; - - const { storedData } = (await chrome.storage.local.get("storedData")) as { - storedData: StoredData; - }; - - if (storedData?.messages?.length) { - messageList.innerHTML = ""; - storedData.messages.forEach((msg, index) => { - const div = createMessageItem(msg, index); - messageList.appendChild(div); - - const keyInput = div.querySelector(".key-input") as HTMLInputElement; - keyInput.addEventListener("input", () => { - updateSummary(elements); - updatePreview(elements); - saveToStorage(elements); - }); - }); - - status.textContent = storedData.summary || ""; - copyButton.disabled = false; - } - - toggleSummaryCheckbox(elements); - - if (storedData?.summaryChecked !== undefined) { - summaryCheckbox.checked = storedData.summaryChecked; - } - - updatePreview(elements); - - const { ticketRegex } = (await chrome.storage.local.get("ticketRegex")) as { - ticketRegex: string; - }; - - if (ticketRegex) { - ticketRegexInput.value = ticketRegex; - CommitMessageFormatter.setTicketRegex(ticketRegex); - } else { - ticketRegexInput.value = `/${CommitMessageFormatter.getDefaultTicketPattern()}/`; - } - - // 처음에는 정규식 입력창을 숨김 - elements.regexInputContainer.classList.add("hidden"); - - const { cleanRegex } = (await chrome.storage.local.get("cleanRegex")) as { - cleanRegex: string; - }; - - if (cleanRegex) { - elements.cleanRegexInput.value = cleanRegex; - CommitMessageFormatter.setCleanRegex(cleanRegex); - } else { - elements.cleanRegexInput.value = - CommitMessageFormatter.getDefaultCleanPattern(); - } -}; - const handleClipboardCopy = ( message: MessagePayload, elements: StatusElements @@ -559,7 +544,6 @@ const handleClipboardCopy = ( keyInput.addEventListener("input", () => { updateSummary(elements); updatePreview(elements); - saveToStorage(elements); }); }); @@ -567,6 +551,7 @@ const handleClipboardCopy = ( updatePreview(elements); button.disabled = false; copyButton.disabled = false; + savePopupState(getCurrentStateFromDOM(elements)); break; case "INTERCEPTOR_COMMIT_FAILED": @@ -581,7 +566,45 @@ const handleClipboardCopy = ( const elements = getStatusElements(); if (!elements) return; + const initialState = await loadPopupState(); + + const { + messageList, + status, + summaryCheckbox, + ticketRegexInput, + cleanRegexInput, + copyButton, + regexInputContainer, + } = elements; + + messageList.innerHTML = ""; + initialState.messages.forEach((msg, index) => { + const div = createMessageItem(msg, index); + messageList.appendChild(div); + + const keyInput = div.querySelector(".key-input") as HTMLInputElement; + keyInput.addEventListener("input", () => { + updateSummary(elements); + updatePreview(elements); + savePopupState(getCurrentStateFromDOM(elements)); + }); + }); + + status.textContent = initialState.summary || "-"; + copyButton.disabled = !initialState.messages.length; + summaryCheckbox.checked = initialState.summaryChecked; + ticketRegexInput.value = initialState.ticketRegex; + cleanRegexInput.value = initialState.cleanRegex; + + CommitMessageFormatter.setTicketRegex(initialState.ticketRegex); + CommitMessageFormatter.setCleanRegex(initialState.cleanRegex); + + toggleSummaryCheckbox(elements); + updatePreview(elements); + + regexInputContainer.classList.add("hidden"); + setupEventListeners(elements); - await loadFromStorage(elements); }); })(); diff --git a/src/services/popupStorageService.ts b/src/services/popupStorageService.ts new file mode 100644 index 0000000..97192a3 --- /dev/null +++ b/src/services/popupStorageService.ts @@ -0,0 +1,48 @@ +import { CommitMessageFormatter } from "./git/parser/CommitMessageFormatter"; + +export interface PopupState { + messages: { + key: string; + text: string; + checked: boolean; + }[]; + summary: string; + summaryChecked: boolean; + ticketRegex: string; + cleanRegex: string; +} + +const STORAGE_KEY = "popupState"; + +export async function loadPopupState(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEY); + const storedState = result[STORAGE_KEY] as Partial | undefined; + + const defaultState: PopupState = { + messages: [], + summary: "", + summaryChecked: true, + ticketRegex: `/${CommitMessageFormatter.getDefaultTicketPattern()}/`, + cleanRegex: CommitMessageFormatter.getDefaultCleanPattern(), + }; + + const state = { ...defaultState, ...storedState }; + + if (!storedState?.ticketRegex) { + state.ticketRegex = defaultState.ticketRegex; + CommitMessageFormatter.setTicketRegex(state.ticketRegex); + } + if (!storedState?.cleanRegex) { + state.cleanRegex = defaultState.cleanRegex; + CommitMessageFormatter.setCleanRegex(state.cleanRegex); + } + return state; +} + +export async function savePopupState(state: PopupState): Promise { + try { + await chrome.storage.local.set({ [STORAGE_KEY]: state }); + } catch (error) { + console.error("Failed to save popup state:", error); + } +}