Skip to content

Commit eb6adb1

Browse files
authored
feat: add 'Copy Scaffolding' dropdown with shell command generator (#53)
1 parent b8ac75c commit eb6adb1

3 files changed

Lines changed: 195 additions & 38 deletions

File tree

src/App.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ const { sidebarWidth, startResizing } = useSidebarResize();
3939
const {
4040
copyStatus,
4141
shareStatus,
42+
oneLinerStatus,
4243
showIndentMenu,
4344
copyToClipboard,
45+
copyOneLiner,
4446
copyShareLink,
4547
downloadConfig,
4648
} = useEditorActions(allFiles, activeFile, reset, getShareUrl);
@@ -151,6 +153,13 @@ onMounted(() => {
151153
category: "Actions",
152154
action: copyShareLink,
153155
},
156+
{
157+
id: "action:one-liner",
158+
label: "Copy One-liner",
159+
description: "Copy a shell command to scaffold .devcontainer files",
160+
category: "Actions",
161+
action: copyOneLiner,
162+
},
154163
{
155164
id: "action:download",
156165
label: "Download Config",
@@ -247,8 +256,10 @@ function handleCursorUpdate(pos: { line: number; col: number }) {
247256
v-model:active-file="activeFile"
248257
:copy-status="copyStatus"
249258
:share-status="shareStatus"
259+
:one-liner-status="oneLinerStatus"
250260
@copy="copyToClipboard"
251261
@share="copyShareLink"
262+
@one-liner="copyOneLiner"
252263
@download="downloadConfig"
253264
@reset="reset"
254265
/>

src/components/layout/EditorTabs.vue

Lines changed: 156 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,42 @@
11
<script setup lang="ts">
2+
import { ref, onMounted, onUnmounted } from "vue";
23
import { useResponsive } from "../../composables/useResponsive";
34
import { URLS } from "../../constants/urls";
45
56
defineProps<{
67
copyStatus: "idle" | "copied";
78
shareStatus: "idle" | "copied";
9+
oneLinerStatus: "idle" | "copied";
810
files: string[];
911
activeFile: string;
1012
}>();
1113
1214
const emit = defineEmits<{
1315
(e: "copy"): void;
1416
(e: "share"): void;
17+
(e: "one-liner"): void;
1518
(e: "download"): void;
1619
(e: "reset"): void;
1720
(e: "update:activeFile", file: string): void;
1821
}>();
1922
2023
const { isMobile } = useResponsive();
24+
const showCopyMenu = ref(false);
25+
26+
function handleClickOutside(e: MouseEvent) {
27+
const target = e.target as HTMLElement;
28+
if (showCopyMenu.value && !target.closest(".copy-dropdown-container")) {
29+
showCopyMenu.value = false;
30+
}
31+
}
32+
33+
onMounted(() => {
34+
window.addEventListener("click", handleClickOutside);
35+
});
36+
37+
onUnmounted(() => {
38+
window.removeEventListener("click", handleClickOutside);
39+
});
2140
</script>
2241

2342
<template>
@@ -89,46 +108,145 @@ const { isMobile } = useResponsive();
89108
</div>
90109
</div>
91110

92-
<div class="flex items-center gap-1 sm:gap-2 px-2 sm:px-4">
93-
<button
94-
@click="$emit('copy')"
95-
class="flex items-center gap-1.5 px-2 py-1.5 rounded hover:bg-ide-accent/10 transition-colors text-ide-text-muted hover:text-ide-text-bright group"
96-
:title="copyStatus === 'copied' ? 'Copied' : 'Copy JSON'"
97-
>
98-
<svg
99-
v-if="copyStatus === 'idle'"
100-
class="w-4 h-4 lg:w-3.5 lg:h-3.5"
101-
fill="none"
102-
viewBox="0 0 24 24"
103-
stroke="currentColor"
111+
<div class="flex items-center gap-1 sm:gap-2 px-2 sm:px-4 relative">
112+
<!-- Copy Dropdown -->
113+
<div class="relative copy-dropdown-container">
114+
<button
115+
@click="showCopyMenu = !showCopyMenu"
116+
class="flex items-center gap-1.5 px-2 py-1.5 rounded hover:bg-ide-accent/10 transition-colors text-ide-text-muted hover:text-ide-text-bright group"
117+
:class="{ 'bg-ide-accent/10 text-ide-text-bright': showCopyMenu }"
118+
title="Copy Options"
104119
>
105-
<path
106-
stroke-linecap="round"
107-
stroke-linejoin="round"
108-
stroke-width="2"
109-
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m-3 8h3m-3 4h3"
110-
/>
111-
</svg>
112-
<svg
113-
v-else
114-
class="w-4 h-4 lg:w-3.5 lg:h-3.5 text-ide-green"
115-
fill="none"
116-
viewBox="0 0 24 24"
117-
stroke="currentColor"
118-
>
119-
<path
120-
stroke-linecap="round"
121-
stroke-linejoin="round"
122-
stroke-width="3"
123-
d="M5 13l4 4L19 7"
124-
/>
125-
</svg>
126-
<span
127-
v-if="!isMobile"
128-
class="text-[10px] font-bold uppercase tracking-widest"
129-
>{{ copyStatus === "copied" ? "Copied" : "Copy" }}</span
120+
<svg
121+
class="w-4 h-4 lg:w-3.5 lg:h-3.5"
122+
fill="none"
123+
viewBox="0 0 24 24"
124+
stroke="currentColor"
125+
>
126+
<path
127+
stroke-linecap="round"
128+
stroke-linejoin="round"
129+
stroke-width="2"
130+
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m-3 8h3m-3 4h3"
131+
/>
132+
</svg>
133+
<span
134+
v-if="!isMobile"
135+
class="text-[10px] font-bold uppercase tracking-widest"
136+
>Copy</span
137+
>
138+
<svg
139+
class="w-2.5 h-2.5 opacity-50 transition-transform duration-200"
140+
:class="{ 'rotate-180': showCopyMenu }"
141+
fill="none"
142+
viewBox="0 0 24 24"
143+
stroke="currentColor"
144+
>
145+
<path
146+
stroke-linecap="round"
147+
stroke-linejoin="round"
148+
stroke-width="3"
149+
d="M19 9l-7 7-7-7"
150+
/>
151+
</svg>
152+
</button>
153+
154+
<!-- Dropdown Menu -->
155+
<div
156+
v-if="showCopyMenu"
157+
class="absolute top-full right-0 mt-2 w-48 bg-ide-sidebar border border-ide-border rounded-lg shadow-2xl z-50 overflow-hidden py-1 animate-in fade-in slide-in-from-top-1 duration-200"
130158
>
131-
</button>
159+
<button
160+
@click="$emit('copy')"
161+
class="w-full flex items-center relative px-3 py-2 hover:bg-ide-accent/10 transition-colors group text-left"
162+
>
163+
<div class="flex items-center gap-2 flex-1 min-w-0 pr-6">
164+
<svg
165+
class="w-3.5 h-3.5 text-ide-text-muted group-hover:text-ide-accent transition-colors shrink-0"
166+
fill="none"
167+
viewBox="0 0 24 24"
168+
stroke="currentColor"
169+
>
170+
<path
171+
stroke-linecap="round"
172+
stroke-linejoin="round"
173+
stroke-width="2"
174+
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
175+
/>
176+
</svg>
177+
<div class="flex flex-col min-w-0">
178+
<span
179+
class="text-[10px] font-bold uppercase tracking-tight text-ide-text-bright truncate"
180+
>Copy to Clipboard</span
181+
>
182+
<span class="text-[9px] text-ide-text-muted truncate"
183+
>Clipboard active file</span
184+
>
185+
</div>
186+
</div>
187+
<svg
188+
v-if="copyStatus === 'copied'"
189+
class="w-3.5 h-3.5 text-ide-green absolute right-3"
190+
fill="none"
191+
viewBox="0 0 24 24"
192+
stroke="currentColor"
193+
>
194+
<path
195+
stroke-linecap="round"
196+
stroke-linejoin="round"
197+
stroke-width="3"
198+
d="M5 13l4 4L19 7"
199+
/>
200+
</svg>
201+
</button>
202+
203+
<div class="h-px bg-ide-border mx-2 my-1"></div>
204+
205+
<button
206+
@click="$emit('one-liner')"
207+
class="w-full flex items-center relative px-3 py-2 hover:bg-ide-accent/10 transition-colors group text-left"
208+
>
209+
<div class="flex items-center gap-2 flex-1 min-w-0 pr-6">
210+
<svg
211+
class="w-3.5 h-3.5 text-ide-text-muted group-hover:text-ide-accent transition-colors shrink-0"
212+
fill="none"
213+
viewBox="0 0 24 24"
214+
stroke="currentColor"
215+
>
216+
<path
217+
stroke-linecap="round"
218+
stroke-linejoin="round"
219+
stroke-width="2"
220+
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
221+
/>
222+
</svg>
223+
<div class="flex flex-col min-w-0">
224+
<span
225+
class="text-[10px] font-bold uppercase tracking-tight text-ide-text-bright truncate"
226+
>Copy Scaffolding</span
227+
>
228+
<span class="text-[9px] text-ide-text-muted truncate"
229+
>Bash one-liner command</span
230+
>
231+
</div>
232+
</div>
233+
<svg
234+
v-if="oneLinerStatus === 'copied'"
235+
class="w-3.5 h-3.5 text-ide-green absolute right-3"
236+
fill="none"
237+
viewBox="0 0 24 24"
238+
stroke="currentColor"
239+
>
240+
<path
241+
stroke-linecap="round"
242+
stroke-linejoin="round"
243+
stroke-width="3"
244+
d="M5 13l4 4L19 7"
245+
/>
246+
</svg>
247+
</button>
248+
</div>
249+
</div>
132250

133251
<button
134252
@click="$emit('share')"

src/composables/useEditorActions.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function useEditorActions(
1010
) {
1111
const copyStatus = ref<"idle" | "copied">("idle");
1212
const shareStatus = ref<"idle" | "copied">("idle");
13+
const oneLinerStatus = ref<"idle" | "copied">("idle");
1314
const showIndentMenu = ref(false);
1415

1516
async function copyToClipboard() {
@@ -25,6 +26,31 @@ export function useEditorActions(
2526
}
2627
}
2728

29+
async function copyOneLiner() {
30+
try {
31+
const files = allFiles.value;
32+
const fileNames = Object.keys(files);
33+
34+
let command = "mkdir -pv .devcontainer";
35+
36+
const fileCommands = fileNames.map((name) => {
37+
const content = files[name].content;
38+
// Use a quoted heredoc (<< 'EOF') to prevent shell expansion
39+
return `cat << 'EOF' > .devcontainer/${name}\n${content}\nEOF`;
40+
});
41+
42+
command = `${command}\n\n${fileCommands.join("\n\n")}`;
43+
44+
await navigator.clipboard.writeText(command);
45+
oneLinerStatus.value = "copied";
46+
setTimeout(() => {
47+
oneLinerStatus.value = "idle";
48+
}, 2000);
49+
} catch (err) {
50+
console.error("Failed to copy one-liner!", err);
51+
}
52+
}
53+
2854
async function copyShareLink() {
2955
try {
3056
await navigator.clipboard.writeText(getShareUrl());
@@ -80,8 +106,10 @@ export function useEditorActions(
80106
return {
81107
copyStatus,
82108
shareStatus,
109+
oneLinerStatus,
83110
showIndentMenu,
84111
copyToClipboard,
112+
copyOneLiner,
85113
copyShareLink,
86114
downloadConfig,
87115
reset,

0 commit comments

Comments
 (0)