Skip to content

Commit a46cdca

Browse files
release: 1.1.4
release: 1.1.4
2 parents 0837d03 + b8bbee9 commit a46cdca

File tree

12 files changed

+341
-60
lines changed

12 files changed

+341
-60
lines changed

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "Tabee: Tab Modifier",
4-
"version": "1.1.3",
4+
"version": "1.1.4",
55
"description": "The original Tab Modifier reborn — rename, organize, and control your browser tabs effortlessly.",
66
"homepage_url": "https://github.com/furybee/chrome-tab-modifier",
77
"action": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tabee",
33
"private": true,
4-
"version": "1.1.3",
4+
"version": "1.1.4",
55
"license": "MIT",
66
"type": "module",
77
"scripts": {

src/Options.vue

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@
3030
</div>
3131
</div>
3232

33-
<div class="navbar-end mr-2">
33+
<div class="navbar-end mr-2 flex gap-2">
34+
<button
35+
v-if="FEATURE_FLAGS.ENABLE_RULE_COPY_PASTE && hasRules && currentContent.component === 'TabRulesPane'"
36+
class="btn btn-xs btn-ghost tooltip tooltip-left flex items-center justify-center"
37+
data-tip="Paste rule from clipboard"
38+
@click="pasteRule"
39+
>
40+
<ClipboardIcon class="!w-4 !h-4" />
41+
Paste rule
42+
</button>
3443
<a
3544
v-if="hasRules && currentContent.component === 'TabRulesPane'"
3645
class="btn btn-xs btn-circle btn-primary"
@@ -88,10 +97,12 @@ import HelpPane from './components/options/center/sections/HelpPane.vue';
8897
import DonationPane from './components/options/center/resources/DonationPane.vue';
8998
import BurgerIcon from './components/icons/BurgerIcon.vue';
9099
import CloseIcon from './components/icons/CloseIcon.vue';
100+
import ClipboardIcon from './components/icons/ClipboardIcon.vue';
91101
import { useRulesStore } from './stores/rules.store.ts';
92102
import Toaster from './components/global/Toaster.vue';
93103
import PlusIcon from './components/icons/PlusIcon.vue';
94104
import { useMenuStore } from './stores/menu.store.ts';
105+
import { FEATURE_FLAGS } from './common/feature-flags.ts';
95106
96107
const emitter: any = inject('emitter');
97108
@@ -177,6 +188,31 @@ const openAddGroupModal = () => {
177188
emitter.emit(GLOBAL_EVENTS.OPEN_ADD_GROUP_MODAL);
178189
};
179190
191+
const pasteRule = async () => {
192+
try {
193+
await rulesStore.pasteRuleFromClipboard();
194+
// Refresh the store to update the UI
195+
await rulesStore.init();
196+
emitter.emit(GLOBAL_EVENTS.SHOW_TOAST, {
197+
type: 'success',
198+
message: 'Rule pasted successfully!',
199+
});
200+
} catch (error) {
201+
if (error instanceof Error) {
202+
emitter.emit(GLOBAL_EVENTS.SHOW_TOAST, {
203+
type: 'error',
204+
message: `Failed to paste rule: ${error.message}`,
205+
});
206+
} else {
207+
emitter.emit(GLOBAL_EVENTS.SHOW_TOAST, {
208+
type: 'error',
209+
message: 'Failed to paste rule from clipboard',
210+
});
211+
}
212+
console.error(error);
213+
}
214+
};
215+
180216
const hasRules = computed<boolean>(() => {
181217
return rulesStore.rules.length > 0;
182218
});

src/background/TabRulesService.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export class TabRulesService {
2828
} catch (error) {
2929
// Content script not loaded (likely a tab that was open before extension reload)
3030
// The content script will apply rules automatically when the tab loads
31-
console.log(`[TabRulesService] Content script not ready for tab ${tab.id}, rule will be applied on next load`);
31+
console.log(
32+
`[TabRulesService] Content script not ready for tab ${tab.id}, rule will be applied on next load`
33+
);
3234
}
3335
}
3436

@@ -42,14 +44,17 @@ export class TabRulesService {
4244
if (!currentTab.id || !currentTab.url) return;
4345

4446
const rule = message.rule as Rule;
47+
const urlFragment = message.url_fragment;
4548

46-
// Check if current tab URL matches the url_matcher pattern
49+
// Check if current tab URL matches the url_matcher pattern (if defined)
4750
// If not, skip unique tab logic (tab doesn't match the rule)
4851
if (rule?.tab?.url_matcher) {
4952
try {
5053
const regex = new RegExp(rule.tab.url_matcher);
5154
if (!regex.test(currentTab.url)) {
52-
console.log('[TabRulesService] Current tab URL does not match url_matcher, skipping unique check');
55+
console.log(
56+
'[TabRulesService] Current tab URL does not match url_matcher, skipping unique check'
57+
);
5358
return;
5459
}
5560
} catch (error) {
@@ -59,7 +64,7 @@ export class TabRulesService {
5964
}
6065

6166
const processedUrlFragment = _processUrlFragment(
62-
message.url_fragment,
67+
urlFragment,
6368
currentTab.url,
6469
rule?.tab?.url_matcher
6570
);
@@ -69,27 +74,50 @@ export class TabRulesService {
6974
for (const tab of tabs) {
7075
if (!tab.url || !tab.id) continue;
7176

77+
// CRITICAL FIX: When url_matcher is NOT defined, compare full URLs
78+
// This prevents closing unrelated tabs (e.g., Gmail when refreshing GitHub)
79+
if (!rule?.tab?.url_matcher) {
80+
// Without url_matcher, we use exact URL comparison for safety
81+
// This ensures only true duplicates (same exact URL) are closed
82+
if (tab.url === currentTab.url && tab.id !== currentTab.id) {
83+
// Remove beforeunload handler from the duplicate tab before closing it
84+
try {
85+
await chrome.scripting.executeScript({
86+
target: { tabId: tab.id },
87+
func: () => {
88+
window.onbeforeunload = null;
89+
},
90+
});
91+
} catch (error) {
92+
// Ignore errors if we can't execute script (e.g., chrome:// pages)
93+
console.log(
94+
`[TabRulesService] Could not remove beforeunload from tab ${tab.id}:`,
95+
error
96+
);
97+
}
98+
99+
// Close the duplicate tab (keep the current tab)
100+
await chrome.tabs.remove(tab.id);
101+
return; // Exit after closing the first duplicate
102+
}
103+
continue;
104+
}
105+
72106
// Skip tabs that don't match the url_matcher pattern
73107
// This prevents closing unrelated tabs that happen to have the same processed fragment
74-
if (rule?.tab?.url_matcher) {
75-
try {
76-
const regex = new RegExp(rule.tab.url_matcher);
77-
if (!regex.test(tab.url)) {
78-
// This tab doesn't match the rule, skip it
79-
continue;
80-
}
81-
} catch (error) {
82-
console.error('[TabRulesService] Invalid url_matcher regex:', error);
108+
try {
109+
const regex = new RegExp(rule.tab.url_matcher);
110+
if (!regex.test(tab.url)) {
111+
// This tab doesn't match the rule, skip it
83112
continue;
84113
}
114+
} catch (error) {
115+
console.error('[TabRulesService] Invalid url_matcher regex:', error);
116+
continue;
85117
}
86118

87119
// Process the fragment for each tab to compare
88-
const tabProcessedFragment = _processUrlFragment(
89-
message.url_fragment,
90-
tab.url,
91-
rule?.tab?.url_matcher
92-
);
120+
const tabProcessedFragment = _processUrlFragment(urlFragment, tab.url, rule.tab.url_matcher);
93121

94122
// Compare processed fragments instead of raw URL
95123
if (tabProcessedFragment === processedUrlFragment && tab.id !== currentTab.id) {

src/background/__tests__/TabRulesService.unique.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,4 +316,89 @@ describe('TabRulesService - GitHub Unique Issue Test', () => {
316316
// This is exactly what the user is experiencing!
317317
});
318318
});
319+
320+
describe('Simple unique without url_matcher', () => {
321+
it('should work with simple CONTAINS detection and no url_matcher', async () => {
322+
const currentTab = {
323+
id: 1,
324+
url: 'https://furybee.org/page1',
325+
} as chrome.tabs.Tab;
326+
327+
const duplicateTab = {
328+
id: 2,
329+
url: 'https://furybee.org/page1',
330+
} as chrome.tabs.Tab;
331+
332+
const differentPageTab = {
333+
id: 3,
334+
url: 'https://furybee.org/page2',
335+
} as chrome.tabs.Tab;
336+
337+
const gmailTab = {
338+
id: 4,
339+
url: 'https://mail.google.com/mail',
340+
} as chrome.tabs.Tab;
341+
342+
const message = {
343+
url_fragment: 'furybee.org',
344+
rule: {
345+
id: 'oo747f6',
346+
name: 'fury',
347+
detection: 'CONTAINS',
348+
url_fragment: 'furybee.org',
349+
tab: {
350+
unique: true,
351+
url_matcher: null,
352+
},
353+
} as Rule,
354+
};
355+
356+
mockChrome.tabs.query.mockImplementation((_queryInfo: any, callback: any) => {
357+
callback([currentTab, duplicateTab, differentPageTab, gmailTab]);
358+
});
359+
360+
await service.handleSetUnique(message, currentTab);
361+
362+
// Should close the exact duplicate
363+
expect(mockChrome.tabs.remove).toHaveBeenCalledWith(duplicateTab.id);
364+
expect(mockChrome.tabs.remove).toHaveBeenCalledTimes(1);
365+
366+
// Should NOT close different page or Gmail
367+
expect(mockChrome.tabs.remove).not.toHaveBeenCalledWith(differentPageTab.id);
368+
expect(mockChrome.tabs.remove).not.toHaveBeenCalledWith(gmailTab.id);
369+
});
370+
371+
it('should NOT close Gmail when it matches detection but not the exact URL', async () => {
372+
const currentTab = {
373+
id: 1,
374+
url: 'https://example.com/page',
375+
} as chrome.tabs.Tab;
376+
377+
const gmailTab = {
378+
id: 2,
379+
url: 'https://mail.google.com/page',
380+
} as chrome.tabs.Tab;
381+
382+
const message = {
383+
url_fragment: 'page',
384+
rule: {
385+
detection: 'CONTAINS',
386+
url_fragment: 'page',
387+
tab: {
388+
unique: true,
389+
url_matcher: null,
390+
},
391+
} as Rule,
392+
};
393+
394+
mockChrome.tabs.query.mockImplementation((_queryInfo: any, callback: any) => {
395+
callback([currentTab, gmailTab]);
396+
});
397+
398+
await service.handleSetUnique(message, currentTab);
399+
400+
// Should NOT close Gmail (different URL)
401+
expect(mockChrome.tabs.remove).not.toHaveBeenCalled();
402+
});
403+
});
319404
});

src/common/feature-flags.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Feature flags for controlling experimental or in-development features
3+
*/
4+
export const FEATURE_FLAGS = {
5+
/**
6+
* Enable copy/paste rule functionality
7+
* This feature allows users to copy rules to clipboard and paste them
8+
*/
9+
ENABLE_RULE_COPY_PASTE: false,
10+
} as const;

src/components/TabHiveSettings.vue

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,7 @@
4646
<div class="card-body">
4747
<div class="flex items-center justify-between mb-2">
4848
<h2 class="card-title">Reject List</h2>
49-
<button
50-
class="btn btn-xs btn-ghost btn-circle"
51-
title="Refresh list"
52-
@click="loadRejectList"
53-
>
54-
<svg
55-
xmlns="http://www.w3.org/2000/svg"
56-
class="h-4 w-4"
57-
fill="none"
58-
viewBox="0 0 24 24"
59-
stroke="currentColor"
60-
>
61-
<path
62-
stroke-linecap="round"
63-
stroke-linejoin="round"
64-
stroke-width="2"
65-
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
66-
/>
67-
</svg>
68-
</button>
49+
<RefreshButton @on-refresh-click="loadRejectList" />
6950
</div>
7051
<p class="text-sm opacity-70 mb-4">
7152
Domains and URLs excluded from auto-close. Tabs matching these patterns will never be
@@ -189,6 +170,7 @@
189170
<script lang="ts" setup>
190171
import { ref, onMounted } from 'vue';
191172
import { useRulesStore } from '../stores/rules.store';
173+
import RefreshButton from './global/RefreshButton.vue';
192174
193175
const store = useRulesStore();
194176
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<svg
3+
class="w-6 h-6"
4+
fill="none"
5+
stroke="currentColor"
6+
stroke-width="1.5"
7+
viewBox="0 0 24 24"
8+
xmlns="http://www.w3.org/2000/svg"
9+
>
10+
<path
11+
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
12+
stroke-linecap="round"
13+
stroke-linejoin="round"
14+
/>
15+
</svg>
16+
</template>

src/components/options/center/sections/TabRules/TableRules.vue

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@
7272
</td>
7373
<td>
7474
<div class="flex justify-end gap-8 invisible group-hover:visible overflow-hidden">
75+
<button
76+
v-if="FEATURE_FLAGS.ENABLE_RULE_COPY_PASTE"
77+
class="btn btn-xs btn-circle tooltip flex items-center justify-items-center"
78+
data-tip="Copy to clipboard"
79+
@click.prevent="(event) => copyRule(event, rule.id)"
80+
>
81+
<ClipboardIcon class="!w-4 !h-4" />
82+
</button>
83+
7584
<button
7685
class="btn btn-xs btn-circle tooltip flex items-center justify-items-center"
7786
data-tip="Duplicate"
@@ -99,13 +108,15 @@
99108
import DuplicateIcon from '../../../../icons/DuplicateIcon.vue';
100109
import DeleteIcon from '../../../../icons/DeleteIcon.vue';
101110
import GripIcon from '../../../../icons/GripIcon.vue';
111+
import ClipboardIcon from '../../../../icons/ClipboardIcon.vue';
102112
import { computed, inject, ref, watch } from 'vue';
103113
import { GLOBAL_EVENTS, Group, Rule, RuleModalParams } from '../../../../../common/types.ts';
104114
import { useRulesStore } from '../../../../../stores/rules.store.ts';
105115
import RefreshButton from '../../../../global/RefreshButton.vue';
106116
import { _chromeGroupColor, _shortify } from '../../../../../common/helpers.ts';
107117
import ColorVisualizer from '../TabGroups/ColorVisualizer.vue';
108118
import draggable from 'vuedraggable';
119+
import { FEATURE_FLAGS } from '../../../../../common/feature-flags.ts';
109120
110121
const props = defineProps<{
111122
rules: Rule[];
@@ -176,6 +187,23 @@ const toggleRule = async (event: MouseEvent, rule: Rule) => {
176187
rules.value = [...rulesStore.rules]; // Update the rules array after toggling
177188
};
178189
190+
const copyRule = async (event: MouseEvent, ruleId: string) => {
191+
event.stopPropagation();
192+
try {
193+
await rulesStore.copyRuleToClipboard(ruleId);
194+
emitter.emit(GLOBAL_EVENTS.SHOW_TOAST, {
195+
type: 'success',
196+
message: 'Rule copied to clipboard! You can now share it with others.',
197+
});
198+
} catch (error) {
199+
emitter.emit(GLOBAL_EVENTS.SHOW_TOAST, {
200+
type: 'error',
201+
message: 'Failed to copy rule to clipboard',
202+
});
203+
console.error(error);
204+
}
205+
};
206+
179207
const duplicateRule = async (event: MouseEvent, ruleId: string) => {
180208
event.stopPropagation();
181209
await rulesStore.duplicateRule(ruleId);

0 commit comments

Comments
 (0)