feat(webview): Drag and Drop 파일 업로드 기능 및 시각적 피드백 구현

This commit is contained in:
2026-04-30 14:12:59 +09:00
parent c69ba168fa
commit 891323288e
+213 -14
View File
@@ -813,8 +813,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
}
if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) {
defaultModel = models[0];
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
// [State Persistence Fix] 저장된 모델이 목록에 없을 때:
// 즉시 강제 리셋하는 대신, 현재 모델 목록의 첫 번째를 '폴백 후보'로만 사용.
// 단, defaultModel이 완전히 없는 경우에만 실제로 저장함.
if (!defaultModel) {
defaultModel = models[0];
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
} else {
// 저장된 모델명은 유지하고, UI에는 첫 번째 모델을 보여주되
// 설정은 건드리지 않아 다음 번에 같은 모델이 다시 로드될 경우 복원 가능하도록 함
logInfo('Saved model not found in current list, using first available as fallback.', { saved: defaultModel, fallback: models[0] });
defaultModel = models[0];
}
}
const defaultIdx = models.indexOf(defaultModel);
@@ -1036,6 +1046,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
.input-box {
background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px;
display: flex; flex-direction: column; gap: 8px; transition: 0.2s;
position: relative; /* Toast 위치 앙커 */
}
.input-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
@@ -1065,6 +1076,56 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
}
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.footer-right { display: flex; align-items: center; gap: 6px; }
.cancel-btn {
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
padding: 6px 10px; border-radius: 6px; font-weight: 600; font-size: 11px;
cursor: pointer; align-items: center; gap: 4px; transition: all 0.15s ease;
}
.cancel-btn:hover { background: rgba(248, 81, 73, 0.12); color: var(--error); border-color: var(--error); }
.stop-btn {
background: rgba(248, 81, 73, 0.15); color: var(--error); border: 1px solid var(--error);
padding: 6px 14px; border-radius: 6px; font-weight: 700; font-size: 12px;
cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
animation: stopPulse 1.2s ease-in-out infinite;
}
.stop-btn:hover { background: rgba(248, 81, 73, 0.3); }
@keyframes stopPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }
50% { box-shadow: 0 0 0 5px rgba(248,81,73,0); }
}
/* --- Drag and Drop Overlay --- */
body.drag-over::after {
content: '📂 파일을 여기에 놓으세요';
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(88, 166, 255, 0.2);
backdrop-filter: blur(4px);
border: 2px dashed var(--accent);
display: flex; align-items: center; justify-content: center;
color: var(--text-bright); font-size: 18px; font-weight: 700;
z-index: 9999; pointer-events: none;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* --- Toast Notification --- */
.toast-notif {
position: absolute; bottom: calc(100% + 8px); left: 50%;
transform: translateX(-50%) translateY(4px);
background: var(--surface); border: 1px solid var(--border-bright);
color: var(--text-primary); font-size: 11px; font-weight: 600;
padding: 7px 14px; border-radius: 20px; white-space: nowrap;
opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-notif.toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-notif.toast-warn { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.08); }
.toast-notif.toast-success { border-color: var(--success); color: var(--success); background: rgba(35,134,54,0.08); }
/* --- Overlays & Others --- */
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
.thinking-bar.active {
@@ -1301,8 +1362,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<button class="icon-btn" id="attachBtn" title="Attach Files">📎</button>
<span id="statusLabel" style="font-size:10px; color:var(--text-dim);">Ready</span>
</div>
<button id="sendBtn" class="send-btn">Send</button>
<div class="footer-right">
<button id="cancelBtn" class="cancel-btn" title="Clear draft" style="display:none;">✕ Clear</button>
<button id="stopBtn" class="stop-btn" title="Stop generation" style="display:none;">■ Stop</button>
<button id="sendBtn" class="send-btn">Send</button>
</div>
</div>
<div id="toastNotif" class="toast-notif"></div>
</div>
<div class="input-group">
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
@@ -1317,10 +1383,62 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const chat = document.getElementById('chat');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const cancelBtn = document.getElementById('cancelBtn');
const toastNotif = document.getElementById('toastNotif');
const thinkingBar = document.getElementById('thinkingBar');
const statusLabel = document.getElementById('statusLabel');
const stepper = document.getElementById('stepper');
// --- Draft State Management ---
let isDraftActive = false;
let _toastTimer = null;
function showToast(msg, type = 'info') {
toastNotif.textContent = msg;
toastNotif.className = 'toast-notif toast-' + type + ' toast-visible';
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
toastNotif.classList.remove('toast-visible');
}, 2500);
}
function setDraftActive(active) {
isDraftActive = active;
cancelBtn.style.display = active ? 'inline-flex' : 'none';
}
// 생성 중/완료 시 Send ⇔ Stop 전환
function setGenerating(generating) {
if (generating) {
sendBtn.style.display = 'none';
stopBtn.style.display = 'inline-flex';
// 생성 중에는 Clear 버튼 숨김
cancelBtn.style.display = 'none';
} else {
stopBtn.style.display = 'none';
sendBtn.style.display = 'inline-flex';
sendBtn.disabled = false;
// Draft 상태에 따라 Clear 버튼 복원
if (isDraftActive) cancelBtn.style.display = 'inline-flex';
}
}
function clearDraft() {
// Step 1: 상태 초기화 (Draft State Reset)
setDraftActive(false);
// Step 2: UI 반영 (Input + Attachments 초기화)
input.value = '';
input.style.height = 'auto';
pendingFiles = [];
renderAttachments();
input.focus();
// Step 3: Toast 알림으로 즉각적 피드백
showToast('✕ 작성 내용이 초기화되었습니다.', 'warn');
Sound.warn();
}
// --- Sound Manager ---
const Sound = {
ctx: null,
@@ -1494,7 +1612,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
break;
case 'streamEnd':
if (streamBody) streamBody.classList.remove('stream-active');
streamBody = null; sendBtn.disabled = false;
streamBody = null;
// \uc0dd\uc131 \uc644\ub8cc \uc2dc Stop \ubc84\ud2bc \uc228\uae30\uace0 Send \ubcf5\uad50
setGenerating(false);
resetStepper();
Sound.success();
break;
@@ -1518,16 +1638,28 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
case 'focusInput':
input.focus();
break;
case 'modelsList':
case 'modelsList': {
modelSel.innerHTML = '';
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
const _savedModel = localStorage.getItem('g1nation_last_model');
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
// LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선)
const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel))
? _savedModel
: msg.value.selected;
msg.value.models.forEach(m => {
const o = document.createElement('option'); o.value = m; o.innerText = m;
if (m === msg.value.selected) o.selected = true;
if (m === _preferredModel) o.selected = true;
modelSel.appendChild(o);
});
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
vscode.postMessage({ type: 'model', value: _savedModel });
}
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
statusLabel.innerText = \`Model: \${_preferredModel}\`;
break;
}
case 'brainProfiles':
brainSel.innerHTML = '';
msg.value.profiles.forEach(p => {
@@ -1602,21 +1734,61 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
attachPreview.appendChild(chip);
});
}
window.removeFile = (i) => { pendingFiles.splice(i, 1); renderAttachments(); };
attachBtn.onclick = () => fileInput.click();
fileInput.onchange = () => {
Array.from(fileInput.files).forEach(file => {
window.removeFile = (i) => {
pendingFiles.splice(i, 1);
renderAttachments();
// 파일 삭제 후 Draft State 재평가
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
};
function processFiles(files) {
if (!files || files.length === 0) return;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
renderAttachments();
setDraftActive(true);
};
reader.readAsDataURL(file);
});
showToast(`${files.length} .`, 'success');
Sound.success();
}
attachBtn.onclick = () => fileInput.click();
fileInput.onchange = () => {
processFiles(fileInput.files);
fileInput.value = '';
};
// --- Drag and Drop Implementation ---
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.remove('drag-over');
}, false);
});
document.body.addEventListener('drop', e => {
const dt = e.dataTransfer;
const files = dt.files;
processFiles(files);
}, false);
function send() {
const val = input.value.trim();
if (!val && pendingFiles.length === 0) return;
@@ -1631,7 +1803,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
negativePrompt: negativePrompt.value.trim() || undefined
});
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
sendBtn.disabled = true; thinkingBar.classList.add('active');
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
setDraftActive(false);
setGenerating(true);
thinkingBar.classList.add('active');
}
sendBtn.onclick = send;
@@ -1642,7 +1817,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
send();
}
});
input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; });
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
// Draft State: 내용이 있으면 cancelBtn 표시
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
});
cancelBtn.onclick = () => clearDraft();
stopBtn.onclick = () => {
vscode.postMessage({ type: 'stopGeneration' });
setGenerating(false);
thinkingBar.classList.remove('active');
showToast('■ 생성이 중단되었습니다.', 'warn');
Sound.warn();
};
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
document.getElementById('newChatBtn').onclick = startNewChat;
@@ -1671,8 +1860,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
};
modelSel.onchange = () => {
vscode.postMessage({ type: 'model', value: modelSel.value });
const _selectedModel = modelSel.value;
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
try {
localStorage.setItem('g1nation_last_model', _selectedModel);
} catch(e) {
console.warn('[G1nation] LocalStorage 저장 실패:', e);
}
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
vscode.postMessage({ type: 'model', value: _selectedModel });
updateInputPlaceholder();
// 상태 레이블 즉시 업데이트
statusLabel.innerText = \`Model: \${_selectedModel}\`;
};
brainSel.onchange = () => {
if (brainSel.value === 'new') {