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);
+ }
+}