12차 G-2 - base 칸으로 머지하는 단축키 (사용자 요청)
12차 B-1 로 왼쪽 파일로 반영하는 길([<] · TakeLineToBase)이 생겼는데 단축키가 없었다. 덩어리를 왼쪽으로 보내는 것은 명령 자체가 없었다 - AcceptBlock (Alt+Shift+Right)은 오른쪽으로만 간다. 키를 어디에 둘지 사용자가 "Alt+Left 도 비어 있지 않냐" 고 물었다. 비어 있지 않았다 - CopyRightToLeft 가 쓰고 있었는데, 2-Way 에서 그것이 하는 일은 이름·화살표와 달리 "이 줄 되돌리기" 였다. 그런데 이름이 가리키는 "왼쪽으로 반영" 이 B-1 로 실제로 생겼다. 그래서 감각이 맞는 쪽으로 넘겼다(사용자 결정). Alt+Right 한 줄 오른쪽으로 그대로 Alt+Shift+Right 덩어리 오른쪽으로 그대로 Alt+Left 이 줄 되돌리기 -> 한 줄 왼쪽 파일로 Alt+Shift+Left (없음) -> 덩어리 왼쪽 파일로 (새로 만듦) 이 줄 되돌리기 Alt+Left -> 단축키 없음, Ctrl+Z 만 Alt+Right 가 이미 2-Way/3-Way 로 갈라져 있으므로 Alt+Left 도 같은 식으로 갈랐다. 3-Way(Take Theirs)는 안 건드렸다. 만든 것 - AcceptBlockToBase · ApplyGroupToBase - AcceptBlock/ApplyGroup 의 거울. 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 와 같은 규칙). 덩어리를 잡는 BlockAround 는 2-Way 에서 이미 CanTakeBase 로 잡으므로 그대로 썼다 - 가져올 수 있는 줄과 보낼 수 있는 줄이 같은 줄이고 방향만 반대다. - [이 덩어리 왼쪽으로] 단추 - [이 덩어리 반영] 옆에. 두 파일 비교에서만 뜬다. 단축키만 두면 찾을 길이 없다. 겪은 것 - RaiseCanExecuteChanged 와 OnCaretRowChanged 에 새 명령을 안 넣어 단추가 꺼진 채였다. CommunityToolkit 커맨드는 CommandManager 를 구독하지 않는다. - 덩어리 명령은 고른 줄이 아니라 CaretRow(편집기 캐럿)를 본다. 시험이 목록을 고르기만 해서 한 줄만 갔다 - Alt+Down 으로 캐럿을 놓아야 했다. 확인 - 화면 g2-verify 21건 실패 0 (연속 세 줄이 다른 재료를 따로 만들어 씀) Alt+Left 는 한 줄만, Ctrl+Z 로 되돌아가고, 덩어리는 셋이 한 번에 가고 한 번에 돌아온다 - 하네스 ShortcutTest 88 -> 101건 (키 겹침 없음, 화살표 넷의 모양이 맞음, 충돌 이동을 안 빼앗음, Alt+Left 설명이 "되돌리기" 라고 거짓말하지 않음) - 회귀 g1 · e2 · e3 · e4 실패 0, 빌드 경고 0 · 오류 0 값으로 확정하지 못한 것: Alt+Shift 조합을 흉내 낸 키로는 못 눌렀다. 윈도우가 입력 언어 전환으로 먼저 채간다 - SendKeys 로는 아예 안 닿고 keybd_event 로는 Shift 가 빠져 Alt+Left 처럼 동작했다. 기존 Alt+Shift+Right 로 견줘 봐도 같아 시험 도구 한계다(C-3 의 DragMove 와 같은 부류). 그래서 덩어리는 같은 명령을 부르는 단추로 확인했다. 사람 손으로 한 번 눌러 보는 것이 남았다. Agent: main (claude-opus-5) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b95a07d028
commit
a86dc1596f
@ -2,6 +2,71 @@
|
|||||||
|
|
||||||
지시서: [../작업지시/12차.md](../작업지시/12차.md)
|
지시서: [../작업지시/12차.md](../작업지시/12차.md)
|
||||||
|
|
||||||
|
## G-2. base 칸으로 머지하는 단축키 (2026-09-30, 사용자 요청 · 완료)
|
||||||
|
|
||||||
|
### 무엇이 없었나
|
||||||
|
|
||||||
|
12차 B-1 로 **왼쪽 파일로 반영**하는 길이 생겼는데(`[<]` 단추 · `TakeLineToBase`) **단축키가 없었다.**
|
||||||
|
덩어리를 왼쪽으로 보내는 것은 명령 자체가 없었다 — `AcceptBlock`(`Alt+Shift+→`)은 오른쪽으로만 간다.
|
||||||
|
|
||||||
|
### 키를 어디에 둘지
|
||||||
|
|
||||||
|
사용자가 "`Alt+←` 도 비어 있지 않냐" 고 물었다. **비어 있지 않았다** — `CopyRightToLeft` 가 쓰고 있었는데,
|
||||||
|
2-Way 에서 그것이 하는 일은 이름·화살표와 달리 **"이 줄 되돌리기"** 였다. 그런데 이름과 화살표가
|
||||||
|
가리키는 "왼쪽으로 반영" 이 B-1 로 **실제로 생겼다.** 그래서 감각이 맞는 쪽으로 넘겼다(사용자 결정).
|
||||||
|
|
||||||
|
| 키 | 전 | 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Alt+→` | 한 줄 오른쪽으로 | 그대로 |
|
||||||
|
| `Alt+Shift+→` | 덩어리 오른쪽으로 | 그대로 |
|
||||||
|
| `Alt+←` | **이 줄 되돌리기**(3-Way: Take Theirs) | **한 줄 왼쪽 파일로**(3-Way 는 Take Theirs 그대로) |
|
||||||
|
| `Alt+Shift+←` | 비어 있음 | **덩어리 왼쪽 파일로**(새로 만듦) |
|
||||||
|
| 이 줄 되돌리기 | `Alt+←` | **단축키 없음 — `Ctrl+Z` 만**(사용자 결정) |
|
||||||
|
|
||||||
|
`Alt+→` 가 이미 2-Way/3-Way 로 갈라져 있으므로 `Alt+←` 도 같은 식으로 갈랐다. 3-Way 는 안 건드렸다.
|
||||||
|
|
||||||
|
### 만든 것
|
||||||
|
|
||||||
|
- `AcceptBlockToBase` · `ApplyGroupToBase` — `AcceptBlock`/`ApplyGroup` 의 거울.
|
||||||
|
주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에(11차 F-2 와 같은 규칙).
|
||||||
|
덩어리를 잡는 `BlockAround` 는 2-Way 에서 이미 `CanTakeBase` 로 잡으므로 **그대로 썼다** —
|
||||||
|
"가져올 수 있는 줄" 과 "보낼 수 있는 줄" 이 같은 줄이고 방향만 반대다.
|
||||||
|
- **[이 덩어리 왼쪽으로] 단추** — `[이 덩어리 반영]` 옆에. 두 파일 비교(`BothWays`)에서만 뜬다.
|
||||||
|
단축키만 두면 찾을 길이 없다.
|
||||||
|
|
||||||
|
### 겪은 것 둘
|
||||||
|
|
||||||
|
**`RaiseCanExecuteChanged` 에 새 명령을 안 넣어 단추가 꺼진 채였다.** CommunityToolkit 의 커맨드는
|
||||||
|
`CommandManager` 를 구독하지 않아 직접 알려야 한다(파일에 이미 그 까닭이 주석으로 있었다).
|
||||||
|
`OnCaretRowChanged` 에도 넣어야 줄을 옮길 때 켜지고 꺼진다.
|
||||||
|
|
||||||
|
**덩어리 명령은 고른 줄이 아니라 `CaretRow`(편집기 캐럿)를 본다.** 시험이 왼쪽 목록을 고르기만 해서
|
||||||
|
한 줄만 갔다 — `Alt+↓`(다음 차이)로 캐럿을 놓아야 했다.
|
||||||
|
|
||||||
|
### 화면으로 확인 (값)
|
||||||
|
|
||||||
|
`g2-verify.ps1` **21건 · 실패 0**. 연속 세 줄이 다른 재료(`speed` · `power` · `delay`)를 따로 만들어 썼다.
|
||||||
|
|
||||||
|
| 확인 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| `Alt+←` 한 줄만 | `speed = 10 → 11`, `power`·`delay` 는 그대로 |
|
||||||
|
| `Ctrl+Z` | 되돌아간다 |
|
||||||
|
| 덩어리 왼쪽으로 | 셋이 **한 번에** 간다 |
|
||||||
|
| 덩어리 되돌리기 | 셋이 **한 번에** 돌아온다 |
|
||||||
|
| 회귀 | `Alt+→` · `[이 덩어리 반영]` 그대로 · `g1`·`e2`·`e3`·`e4` 실패 0 |
|
||||||
|
|
||||||
|
하네스 `ShortcutTest` 88 → **101건**(G-2 +13): 키가 겹치지 않음 · 화살표 넷의 모양이 맞음
|
||||||
|
(Alt = 한 줄, Alt+Shift = 덩어리) · 충돌 이동(`Alt+Shift+↑↓`)을 안 빼앗음 ·
|
||||||
|
**`Alt+←` 설명이 "되돌리기" 라고 거짓말하지 않음**.
|
||||||
|
|
||||||
|
### 값으로 확정하지 못한 것
|
||||||
|
|
||||||
|
**`Alt+Shift+←` 와 `Alt+Shift+→` 를 흉내 낸 키로는 못 눌렀다.** 윈도우가 `Alt+Shift` 를
|
||||||
|
입력 언어 전환으로 먼저 채간다 — `SendKeys` 로는 아예 안 닿고, `keybd_event` 로는 `Shift` 가 빠져
|
||||||
|
`Alt+←` 처럼 동작했다. **기존 `Alt+Shift+→` 로 견줘 봐도 똑같았으므로 시험 도구 한계다**(C-3 의
|
||||||
|
`DragMove` 와 같은 부류). 그래서 덩어리는 **같은 명령을 부르는 단추로** 확인했다.
|
||||||
|
사람 손으로 `Alt+Shift+←` 를 한 번 눌러 보는 것이 남았다.
|
||||||
|
|
||||||
## G-1. undo 가 계속 이어진다 (2026-09-30, 사용자 보고 · 완료)
|
## G-1. undo 가 계속 이어진다 (2026-09-30, 사용자 보고 · 완료)
|
||||||
|
|
||||||
### 먼저 재현 (화면, 값)
|
### 먼저 재현 (화면, 값)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
| 날짜 | 항목 | 커밋 |
|
| 날짜 | 항목 | 커밋 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-09-30 | **12차 G-2** — 사용자 요청 "base 영역에 머지할 수 있는 단축키". 12차 B-1 로 **왼쪽 파일로 반영**하는 길(`[<]` · `TakeLineToBase`)이 생겼는데 단축키가 없었고, 덩어리를 왼쪽으로 보내는 것은 명령 자체가 없었다(`AcceptBlock` 은 오른쪽으로만 간다). `Alt+←` 는 비어 있지 않았는데 — `CopyRightToLeft` 가 쓰고 있었고 2-Way 에서 하는 일이 이름·화살표와 달리 **"이 줄 되돌리기"** 였다. 그 이름이 가리키는 일이 B-1 로 실제로 생겼으므로 그쪽으로 넘겼다(사용자 결정). **`Alt+←` = 한 줄 왼쪽으로 · `Alt+Shift+←` = 덩어리 왼쪽으로**(새로 만듦) · 되돌리기는 `Ctrl+Z` 만. 3-Way(Take Theirs)는 안 건드렸다. 찾을 수 있게 **[이 덩어리 왼쪽으로] 단추**도 달았다 (두 파일 비교에서만 뜬다). `ApplyGroupToBase` 는 `ApplyGroup` 의 거울 — 주석은 덩어리 첫 줄 위 한 줄, 되돌리기는 한 번에. 덩어리를 잡는 `BlockAround` 는 2-Way 에서 이미 `CanTakeBase` 로 잡으므로 그대로 썼다. 겪은 것: `RaiseCanExecuteChanged`·`OnCaretRowChanged` 에 새 명령을 안 넣어 단추가 꺼진 채였고, 덩어리 명령은 고른 줄이 아니라 `CaretRow`(편집기 캐럿)를 본다. 화면 `g2-verify` 21건 실패 0 · 하네스 `ShortcutTest` 88 → 101건 · 회귀 `g1`·`e2`·`e3`·`e4` 실패 0. **`Alt+Shift` 조합은 윈도우가 입력 언어 전환으로 먼저 채가 흉내 낸 키로 못 눌렀다** — 기존 `Alt+Shift+→` 로 견줘 봐도 같아 시험 도구 한계이고(C-3 의 DragMove 와 같은 부류), 덩어리는 같은 명령을 부르는 단추로 확인했다. 사람 손으로 한 번 눌러 보는 것이 남았다 | 아래 |
|
||||||
| 2026-09-30 | **12차 G-1** — 사용자 보고 "undo 가 계속 이어진다". 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 **앞 파일 글(234자) ↔ 지금 파일 글(228자)을 끝없이 오갔다** — 40번을 눌러도 안 끝났고, 글자를 하나도 안 쳐도 났다. `Ctrl+Z` 는 우리 목록(`_undo`·`_textUndo`)이 비면 일부러 키를 먹지 않는데(그래야 글자 되돌리기가 산다), 그러면 키가 `TextBox` 자체 되돌리기로 넘어간다. WPF 의 그것은 한도가 없고 **프로그램이 코드로 넣은 글까지 기록**해서, 짝을 바꾸면 앞 파일 글을 편집기에 되살렸다. 되살아난 글을 `Reflow` 가 "사용자가 친 것" 으로 보고 모델을 그쪽으로 되돌리며 되돌리기에 다시 쌓고 다시하기를 지워, 두 상태에 갇혔다(기록을 심어 고리를 떴다 — `221 = 234 − 13` 으로 편집기가 쥔 것이 앞 파일임을 값으로 확인). **고침은 한 줄** — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 실패 0(30번 눌러도 길이 자취가 `228` 하나), 편집 관련 다섯 벌(`e1`·`e2`·`e2-reg`·`e3`·`e4`) 회귀 실패 0. **헛짚은 것 둘도 남긴다** — "문서가 바뀔 때 TextBox 기록을 비우면 된다"(`EditorUndoGate` 를 만들어 하네스 16건까지 붙였다)와 "재정렬 타이머가 뒤늦게 터진다" 는 둘 다 증상이 안 없어져 **되돌렸다**. 검증 안 된 동작 변경은 남기지 않는다. 대신 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 | 아래 |
|
| 2026-09-30 | **12차 G-1** — 사용자 보고 "undo 가 계속 이어진다". 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 **앞 파일 글(234자) ↔ 지금 파일 글(228자)을 끝없이 오갔다** — 40번을 눌러도 안 끝났고, 글자를 하나도 안 쳐도 났다. `Ctrl+Z` 는 우리 목록(`_undo`·`_textUndo`)이 비면 일부러 키를 먹지 않는데(그래야 글자 되돌리기가 산다), 그러면 키가 `TextBox` 자체 되돌리기로 넘어간다. WPF 의 그것은 한도가 없고 **프로그램이 코드로 넣은 글까지 기록**해서, 짝을 바꾸면 앞 파일 글을 편집기에 되살렸다. 되살아난 글을 `Reflow` 가 "사용자가 친 것" 으로 보고 모델을 그쪽으로 되돌리며 되돌리기에 다시 쌓고 다시하기를 지워, 두 상태에 갇혔다(기록을 심어 고리를 떴다 — `221 = 234 − 13` 으로 편집기가 쥔 것이 앞 파일임을 값으로 확인). **고침은 한 줄** — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 실패 0(30번 눌러도 길이 자취가 `228` 하나), 편집 관련 다섯 벌(`e1`·`e2`·`e2-reg`·`e3`·`e4`) 회귀 실패 0. **헛짚은 것 둘도 남긴다** — "문서가 바뀔 때 TextBox 기록을 비우면 된다"(`EditorUndoGate` 를 만들어 하네스 16건까지 붙였다)와 "재정렬 타이머가 뒤늦게 터진다" 는 둘 다 증상이 안 없어져 **되돌렸다**. 검증 안 된 동작 변경은 남기지 않는다. 대신 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 | 아래 |
|
||||||
| 2026-09-30 | **플랫폼을 공용 저장소로 합침** — 우리가 따로 쓰던 플랫폼 소스(`D:\Sorce\ubsrepo`, 원격이 `Ubisam-MergeTool` 이었다)에 얹어 둔 두 커밋을 **진짜 플랫폼 저장소** `D:\Sorce\UbisamCommonPlatform`(사내 Gitea) 로 옮겼다. `6991dfd` 이후 양쪽이 갈라져 있었고 — 우리 2커밋(트레이 · 창 자리 기억 · 세 갈래 묻는 창 · 탭 줄 크기 · 헤더 접기) vs 공용 4커밋(우클릭 메뉴 테마 · 앱 아이콘 · 업데이트 기본값 · HANDOFF) — **소스 파일이 하나도 겹치지 않아** 부딪힌 것은 `changelog.md` 한 군데뿐이었다(양쪽 줄 다 살려 날짜순으로 합침). 공용 쪽에 빠져 있던 문서도 채웠다: 가이드 8-5 에 헤더 접기, `HANDOFF.md` 5·6절에 우리 기능 — **C-3 은 플랫폼 changelog 에도 줄이 없었다.** 빌드해 이 PC 의 `D:\UbisamPlatform\bin` 까지 반영(오류 0 · 경고 4, 배포본 해시로 확인). `dist\` 는 손대지 않았다 — 사내 다른 PC 는 그대로다. 화면 회귀 아홉 벌 **208건 실패 0 · 예외 0**, MergeHub 재빌드 경고 0 · 오류 0. **앞으로 플랫폼은 공용 저장소에서만 고친다** | 아래 |
|
| 2026-09-30 | **플랫폼을 공용 저장소로 합침** — 우리가 따로 쓰던 플랫폼 소스(`D:\Sorce\ubsrepo`, 원격이 `Ubisam-MergeTool` 이었다)에 얹어 둔 두 커밋을 **진짜 플랫폼 저장소** `D:\Sorce\UbisamCommonPlatform`(사내 Gitea) 로 옮겼다. `6991dfd` 이후 양쪽이 갈라져 있었고 — 우리 2커밋(트레이 · 창 자리 기억 · 세 갈래 묻는 창 · 탭 줄 크기 · 헤더 접기) vs 공용 4커밋(우클릭 메뉴 테마 · 앱 아이콘 · 업데이트 기본값 · HANDOFF) — **소스 파일이 하나도 겹치지 않아** 부딪힌 것은 `changelog.md` 한 군데뿐이었다(양쪽 줄 다 살려 날짜순으로 합침). 공용 쪽에 빠져 있던 문서도 채웠다: 가이드 8-5 에 헤더 접기, `HANDOFF.md` 5·6절에 우리 기능 — **C-3 은 플랫폼 changelog 에도 줄이 없었다.** 빌드해 이 PC 의 `D:\UbisamPlatform\bin` 까지 반영(오류 0 · 경고 4, 배포본 해시로 확인). `dist\` 는 손대지 않았다 — 사내 다른 PC 는 그대로다. 화면 회귀 아홉 벌 **208건 실패 0 · 예외 0**, MergeHub 재빌드 경고 0 · 오류 0. **앞으로 플랫폼은 공용 저장소에서만 고친다** | 아래 |
|
||||||
| 2026-09-28 | **12차 C-3** — 플랫폼 **헤더 줄도 접을 수 있게**. 지금까지 `TopBarCollapsed` 설정은 있었지만 그것을 켜고 끄는 자리가 **설정 화면 체크박스 하나뿐**이었고, 접으면 되살릴 띠도 단축키도 없어 다시 설정 화면으로 들어가야 했다. 아래 탭 줄 접기(11차 F-4)와 같은 방식으로 맞췄다 — 창 맨 위에 16 DIP 짜리 띠(`⌄ 헤더 펴기 · Ctrl+F11`, 사용자 결정대로 화살표와 안내 글만 두고 시계 · 이름은 넣지 않았다), **Ctrl+F11** 로 접고 펴기(아래 탭 줄 F11 과 짝), 하단 동작 줄에 [헤더 접기]. 접은 상태는 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값을 건드리지 않게). 띠는 단추가 아니라 Border 다 — 한 자리에서 끌기와 누르기를 둘 다 받아야 하는데 단추에 DragMove 를 붙이면 마우스를 놓을 때 Click 이 오지 않아 펴지지 않는다. 끌린 거리가 없으면 누른 것으로 본다. 값: 모듈 시작 **144px → 51px(93px · 62 DIP 벌었다)**, 다시 켜도 접힌 채, F11 은 그대로 아래 탭 줄만. 하네스 `PlatformCfgTest` 29 → 42건(옛 플랫폼 소스를 가리키던 것도 새 저장소로 옮김), 화면 21건 통과. **접은 띠를 끌면 창이 따라오는가는 값으로 확정하지 못했다** — 흉내 낸 마우스로는 WPF DragMove 가 움직이지 않고, 기존 헤더 끌기로 견줘 봐도 같았다(시험 도구 한계). **배포함**(사용자 확인 뒤) — 모든 사내 프로그램에 함께 간다. 기본은 펴짐이라 아무것도 안 하면 달라지는 것이 없다. 플랫폼이 바뀌었으니 **그 위에 얹혀 도는 12차 화면 시험 아홉 벌을 2026-09-30 에 전부 다시 돌렸다 — 208건 실패 0 · 예외 0**(`run-all-c3.ps1`, 한 벌마다 앞 벌이 남긴 창 · 보관함을 치우고 들어간다). 끝나고 설정 네 파일을 시작 기준본과 바이트까지 같게 되돌렸다 | 아래 |
|
| 2026-09-28 | **12차 C-3** — 플랫폼 **헤더 줄도 접을 수 있게**. 지금까지 `TopBarCollapsed` 설정은 있었지만 그것을 켜고 끄는 자리가 **설정 화면 체크박스 하나뿐**이었고, 접으면 되살릴 띠도 단축키도 없어 다시 설정 화면으로 들어가야 했다. 아래 탭 줄 접기(11차 F-4)와 같은 방식으로 맞췄다 — 창 맨 위에 16 DIP 짜리 띠(`⌄ 헤더 펴기 · Ctrl+F11`, 사용자 결정대로 화살표와 안내 글만 두고 시계 · 이름은 넣지 않았다), **Ctrl+F11** 로 접고 펴기(아래 탭 줄 F11 과 짝), 하단 동작 줄에 [헤더 접기]. 접은 상태는 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값을 건드리지 않게). 띠는 단추가 아니라 Border 다 — 한 자리에서 끌기와 누르기를 둘 다 받아야 하는데 단추에 DragMove 를 붙이면 마우스를 놓을 때 Click 이 오지 않아 펴지지 않는다. 끌린 거리가 없으면 누른 것으로 본다. 값: 모듈 시작 **144px → 51px(93px · 62 DIP 벌었다)**, 다시 켜도 접힌 채, F11 은 그대로 아래 탭 줄만. 하네스 `PlatformCfgTest` 29 → 42건(옛 플랫폼 소스를 가리키던 것도 새 저장소로 옮김), 화면 21건 통과. **접은 띠를 끌면 창이 따라오는가는 값으로 확정하지 못했다** — 흉내 낸 마우스로는 WPF DragMove 가 움직이지 않고, 기존 헤더 끌기로 견줘 봐도 같았다(시험 도구 한계). **배포함**(사용자 확인 뒤) — 모든 사내 프로그램에 함께 간다. 기본은 펴짐이라 아무것도 안 하면 달라지는 것이 없다. 플랫폼이 바뀌었으니 **그 위에 얹혀 도는 12차 화면 시험 아홉 벌을 2026-09-30 에 전부 다시 돌렸다 — 208건 실패 0 · 예외 0**(`run-all-c3.ps1`, 한 벌마다 앞 벌이 남긴 창 · 보관함을 치우고 들어간다). 끝나고 설정 네 파일을 시작 기준본과 바이트까지 같게 되돌렸다 | 아래 |
|
||||||
|
|||||||
@ -443,6 +443,7 @@
|
|||||||
|
|
||||||
| 상태 | 항목 |
|
| 상태 | 항목 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| **완료** | **base 칸으로 머지하는 단축키**(사용자 요청, 12차 G-2) — `Alt+←` 를 **한 줄 왼쪽 파일로**(`[<]` 와 같은 일), `Alt+Shift+←` 를 **덩어리 왼쪽으로**(명령을 새로 만듦). `Alt+←` 는 원래 2-Way 에서 "이 줄 되돌리기" 였는데 이름·화살표와 하는 일이 달랐고, 그 뜻대로 하는 일이 B-1 로 생겨 넘겼다(되돌리기는 `Ctrl+Z` 만, 사용자 결정). 3-Way 는 안 건드림. 찾을 수 있게 **[이 덩어리 왼쪽으로] 단추**도 달았다. 화면 `g2-verify` 21건 · 하네스 `ShortcutTest` 88 → 101건. **`Alt+Shift` 조합은 윈도우가 입력 언어 전환으로 채가 흉내 낸 키로 못 눌렀다** — 기존 `Alt+Shift+→` 도 같아 시험 도구 한계이고, 덩어리는 단추로 확인했다 |
|
||||||
| **완료** | **undo 가 계속 이어진다**(사용자 보고, 12차 G-1) — 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 앞 파일 글 ↔ 지금 파일 글을 **끝없이 오갔다**(40번을 눌러도 안 끝남, 글자를 안 쳐도 남). 우리 목록이 비면 `Ctrl+Z` 가 키를 안 먹어 `TextBox` 자체 되돌리기로 넘어가고, 그것이 **앞 파일 글을 되살리면** `Reflow` 가 그걸 "친 것" 으로 보고 모델을 되돌리며 다시 쌓았다. 고침은 한 줄 — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 · 편집 관련 다섯 벌 회귀 실패 0. 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 |
|
| **완료** | **undo 가 계속 이어진다**(사용자 보고, 12차 G-1) — 짝을 바꾼 뒤 `Ctrl+Z` 를 누르면 앞 파일 글 ↔ 지금 파일 글을 **끝없이 오갔다**(40번을 눌러도 안 끝남, 글자를 안 쳐도 남). 우리 목록이 비면 `Ctrl+Z` 가 키를 안 먹어 `TextBox` 자체 되돌리기로 넘어가고, 그것이 **앞 파일 글을 되살리면** `Reflow` 가 그걸 "친 것" 으로 보고 모델을 되돌리며 다시 쌓았다. 고침은 한 줄 — `LocalEditor` 에 `IsUndoEnabled="False"`. 되돌리기는 우리 목록만 쓴다. 화면 `g1-verify` 14건 · 편집 관련 다섯 벌 회귀 실패 0. 글자 **한 자 단위** 되돌리기는 없어지고 "타이핑이 멈춘 자리" 가 한 걸음이 됐다 |
|
||||||
| **완료** | `[<]` 단추가 있는 줄만 글이 밀린다(사용자 보고, 그림으로 짚어 줌) — 단추 칸 폭이 `Auto` 라 단추가 보이는 줄만 뒤 칸이 밀렸다(BASE 는 번호까지, LOCAL 은 코드가). `GutterBothWaysWidth` 로 두 칸이 같은 자리를 늘 잡아 두고, 모드에 따라 0 ↔ 단추 폭으로 갈아 끼운다. 편집기 여백도 같이 민다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로 |
|
| **완료** | `[<]` 단추가 있는 줄만 글이 밀린다(사용자 보고, 그림으로 짚어 줌) — 단추 칸 폭이 `Auto` 라 단추가 보이는 줄만 뒤 칸이 밀렸다(BASE 는 번호까지, LOCAL 은 코드가). `GutterBothWaysWidth` 로 두 칸이 같은 자리를 늘 잡아 두고, 모드에 따라 0 ↔ 단추 폭으로 갈아 끼운다. 편집기 여백도 같이 민다. 값으로 확인: 한 칸의 모든 줄이 같은 x(BASE 137 · LOCAL 1283), 글자 크기 · Git 모드도 그대로 |
|
||||||
| **완료** | 왼쪽 줄 번호가 밀린다(위를 쫓다 따로 찾음) — 12차 B-1 로 왼쪽에도 반영할 수 있게 됐는데 왼쪽 번호를 다시 매기는 자리가 없었다. 줄을 넣으면 그 줄은 번호가 비고 뒤 줄은 옛 번호(`01 02 · 03 04 05 · 06`), 저장하면 8줄인데 번호는 6까지. `RenumberBase` 를 두어 두 파일 비교에서만 왼쪽 줄을 1부터 다시 맨다. "머지 반영함" 표시가 BASE 번호를 열쇠로 쓰므로 그 열쇠도 함께 옮긴다. Git · 네 칸은 손대지 않는다 |
|
| **완료** | 왼쪽 줄 번호가 밀린다(위를 쫓다 따로 찾음) — 12차 B-1 로 왼쪽에도 반영할 수 있게 됐는데 왼쪽 번호를 다시 매기는 자리가 없었다. 줄을 넣으면 그 줄은 번호가 비고 뒤 줄은 옛 번호(`01 02 · 03 04 05 · 06`), 저장하면 8줄인데 번호는 6까지. `RenumberBase` 를 두어 두 파일 비교에서만 왼쪽 줄을 1부터 다시 맨다. "머지 반영함" 표시가 BASE 번호를 열쇠로 쓰므로 그 열쇠도 함께 옮긴다. Git · 네 칸은 손대지 않는다 |
|
||||||
|
|||||||
@ -24,6 +24,7 @@ namespace MergeHub.Services
|
|||||||
NextFile,
|
NextFile,
|
||||||
ToggleBookmark,
|
ToggleBookmark,
|
||||||
AcceptBlock,
|
AcceptBlock,
|
||||||
|
AcceptBlockToBase, // 12차 G-2 - 덩어리를 왼쪽 파일로
|
||||||
NextConflict,
|
NextConflict,
|
||||||
PrevConflict,
|
PrevConflict,
|
||||||
AutoMerge,
|
AutoMerge,
|
||||||
@ -123,7 +124,13 @@ namespace MergeHub.Services
|
|||||||
"화살표는 WinMerge 와 같이 '어느 쪽으로 복사하는가'를 가리킨다. 3-Way 에서 고칠 수 있는 칸은 " +
|
"화살표는 WinMerge 와 같이 '어느 쪽으로 복사하는가'를 가리킨다. 3-Way 에서 고칠 수 있는 칸은 " +
|
||||||
"아래 MERGED 하나뿐이므로, 왼쪽 끝인 LOCAL(Mine) 을 결과로 가져오는 것이 여기서의 '오른쪽으로'다."),
|
"아래 MERGED 하나뿐이므로, 왼쪽 끝인 LOCAL(Mine) 을 결과로 가져오는 것이 여기서의 '오른쪽으로'다."),
|
||||||
new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집",
|
new ShortcutEntry(ShortcutAction.CopyRightToLeft, Key.Left, ModifierKeys.Alt, "병합·편집",
|
||||||
"오른쪽 → 왼쪽 반영 (2-Way: 이 줄 되돌리기 / 3-Way: Take Theirs)"),
|
"오른쪽 → 왼쪽 반영 (2-Way: 이 줄을 왼쪽 파일로 / 3-Way: Take Theirs)",
|
||||||
|
"12차 G-2 에 뜻을 바로잡았다. 예전에는 2-Way 에서 '이 줄 되돌리기'였는데, 이름과 화살표가 " +
|
||||||
|
"가리키는 '왼쪽으로 반영'이 12차 B-1 로 실제로 생겼다([<] 단추). 화살표는 늘 어느 쪽으로 " +
|
||||||
|
"가는가를 가리켜야 하므로 그쪽으로 넘겼다. 되돌리기는 Ctrl+Z 가 한다."),
|
||||||
|
new ShortcutEntry(ShortcutAction.AcceptBlockToBase, Key.Left, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
|
||||||
|
"이 덩어리를 왼쪽 파일로 - 이어지는 구간을 한 번에 (2-Way)",
|
||||||
|
"오른쪽으로 보내는 Alt+Shift+→ 의 거울이다."),
|
||||||
|
|
||||||
new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
|
new ShortcutEntry(ShortcutAction.NextConflict, Key.Down, ModifierKeys.Alt | ModifierKeys.Shift, "병합·편집",
|
||||||
"다음 충돌 (3-Way)", "차이가 아니라 <아직 안 정한 충돌>만 딛는다. 자동 병합과 짝이다."),
|
"다음 충돌 (3-Way)", "차이가 아니라 <아직 안 정한 충돌>만 딛는다. 자동 병합과 짝이다."),
|
||||||
@ -136,7 +143,8 @@ namespace MergeHub.Services
|
|||||||
"줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."),
|
"줄 하나씩만 되면 연속 다섯 줄을 다섯 번 눌러야 한다."),
|
||||||
|
|
||||||
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
|
new ShortcutEntry(ShortcutAction.Undo, Key.Z, ModifierKeys.Control, "병합·편집", "실행 취소",
|
||||||
"머지 반영을 되돌린다. 되돌릴 머지 작업이 없으면 편집기의 글자 되돌리기가 그대로 동작한다."),
|
"머지 반영과 편집기에 친 글을 함께 되돌린다. 한 걸음은 <타이핑이 멈춘 자리>다(12차 G-1). " +
|
||||||
|
"12차 G-2 부터 '이 줄만 되돌리기'도 이 키가 맡는다 - Alt+← 는 왼쪽으로 반영하는 자리가 됐다."),
|
||||||
new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"),
|
new ShortcutEntry(ShortcutAction.Redo, Key.Y, ModifierKeys.Control, "병합·편집", "다시 실행"),
|
||||||
|
|
||||||
new ShortcutEntry(ShortcutAction.ToggleBookmark, Key.M, ModifierKeys.Control, "이동·북마크", "지금 줄 북마크 설정/해제"),
|
new ShortcutEntry(ShortcutAction.ToggleBookmark, Key.M, ModifierKeys.Control, "이동·북마크", "지금 줄 북마크 설정/해제"),
|
||||||
|
|||||||
@ -912,6 +912,74 @@ namespace MergeHub.ViewModels
|
|||||||
|
|
||||||
private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true };
|
private bool CanTakeLineToBase(MergeLine? line) => BothWays && line is { CanTakeBase: true };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 캐럿이 있는 자리에서 <b>왼쪽으로 보낼 수 있는</b> 덩어리를 잡을 수 있는가(12차 G-2).
|
||||||
|
/// 2-Way 에서 <see cref="BlockAround"/> 는 <c>CanTakeBase</c> 로 덩어리를 잡으므로 그대로 쓴다 -
|
||||||
|
/// "가져올 수 있는 줄" 과 "보낼 수 있는 줄" 이 같은 줄이다. 방향만 반대다.
|
||||||
|
/// </summary>
|
||||||
|
private bool CanAcceptBlockToBase() => BothWays && BlockAround(CaretRow).Count > 0;
|
||||||
|
|
||||||
|
/// <summary>이 덩어리를 통째로 왼쪽 파일로 반영한다(12차 G-2). 한 줄짜리 <see cref="TakeLineToBase"/> 의 덩어리판.</summary>
|
||||||
|
[RelayCommand(CanExecute = nameof(CanAcceptBlockToBase))]
|
||||||
|
private void AcceptBlockToBase()
|
||||||
|
{
|
||||||
|
var block = BlockAround(CaretRow);
|
||||||
|
if (block.Count == 0) return;
|
||||||
|
|
||||||
|
var count = ApplyGroupToBase(block, "덩어리");
|
||||||
|
if (count > 0) MessageUtil.ShowSuccessToast($"{count}줄 왼쪽에 반영");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 여러 줄을 <b>한 묶음으로</b> 왼쪽 파일에 반영한다(12차 G-2).
|
||||||
|
/// 오른쪽으로 보내는 <see cref="ApplyGroup"/> 의 거울 - 주석은 덩어리 첫 줄 위 한 줄,
|
||||||
|
/// 되돌리기는 한 번에(11차 F-2 와 같은 규칙).
|
||||||
|
/// </summary>
|
||||||
|
private int ApplyGroupToBase(IReadOnlyList<MergeLine> rows, string what)
|
||||||
|
{
|
||||||
|
var group = ++_lastGroup;
|
||||||
|
var changed = 0;
|
||||||
|
int? first = null, last = null;
|
||||||
|
|
||||||
|
foreach (var line in rows.ToList())
|
||||||
|
{
|
||||||
|
if (line.IsMergeNote) continue;
|
||||||
|
var number = line.HasLocal ? line.LocalNumber : line.BaseNumber;
|
||||||
|
// group 이 0 이 아니므로 ApplyToBase 는 줄마다 주석을 달지 않는다 - 아래에서 한 줄만 단다.
|
||||||
|
if (!line.CanTakeBase || !ApplyToBase(line, requery: false, group: group)) continue;
|
||||||
|
|
||||||
|
changed++;
|
||||||
|
if (number is int n) { first ??= n; last = n; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed == 0)
|
||||||
|
{
|
||||||
|
AfterEdit();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var where = first is null ? string.Empty : first == last ? $"{first}행 " : $"{first}~{last}행 ";
|
||||||
|
var description = $"{where}{what} 왼쪽에 반영 ({changed}줄)";
|
||||||
|
|
||||||
|
var at = rows.FirstOrDefault(r => r.HasLocal && !r.IsMergeNote && Lines.Contains(r));
|
||||||
|
if (at is not null && _workspace.Settings.InsertMergeComment)
|
||||||
|
{
|
||||||
|
var note = BuildMergeNote(MergeNames.LocalSource(LocalSourceName));
|
||||||
|
_undo.Push(new LineEdit(at, at.BaseText, at.HasBase, at.BaseText, at.HasBase,
|
||||||
|
EditKind.NoteOnly, description)
|
||||||
|
{
|
||||||
|
Side = MergeLine.TextPane.Base,
|
||||||
|
InsertedNote = InsertBaseNoteAbove(at, note),
|
||||||
|
Group = group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearRedo();
|
||||||
|
_workspace.AppendLog(description);
|
||||||
|
AfterEdit();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>[<] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary>
|
/// <summary>[<] - 이 줄을 왼쪽 파일로 반영한다(12차 B-1).</summary>
|
||||||
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
|
[RelayCommand(CanExecute = nameof(CanTakeLineToBase))]
|
||||||
private void TakeLineToBase(MergeLine? line)
|
private void TakeLineToBase(MergeLine? line)
|
||||||
@ -1322,6 +1390,7 @@ namespace MergeHub.ViewModels
|
|||||||
SavePlainCommand.NotifyCanExecuteChanged();
|
SavePlainCommand.NotifyCanExecuteChanged();
|
||||||
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
SaveLeftPlainCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
||||||
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
TakeLineToBaseCommand.NotifyCanExecuteChanged(); // 12차 B-1
|
||||||
|
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2
|
||||||
ExportReportCommand.NotifyCanExecuteChanged();
|
ExportReportCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
AutoMergeCommand.NotifyCanExecuteChanged();
|
AutoMergeCommand.NotifyCanExecuteChanged();
|
||||||
@ -3552,6 +3621,7 @@ namespace MergeHub.ViewModels
|
|||||||
partial void OnCaretRowChanged(int value)
|
partial void OnCaretRowChanged(int value)
|
||||||
{
|
{
|
||||||
AcceptBlockCommand.NotifyCanExecuteChanged();
|
AcceptBlockCommand.NotifyCanExecuteChanged();
|
||||||
|
AcceptBlockToBaseCommand.NotifyCanExecuteChanged(); // 12차 G-2 - 왼쪽으로 보내는 짝
|
||||||
AcceptMemberCommand.NotifyCanExecuteChanged();
|
AcceptMemberCommand.NotifyCanExecuteChanged();
|
||||||
OnPropertyChanged(nameof(MemberHint));
|
OnPropertyChanged(nameof(MemberHint));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1780,10 +1780,18 @@
|
|||||||
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
|
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
|
||||||
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
|
ToolTip="선택한 줄을 REMOTE(Theirs) 내용으로"/>
|
||||||
|
|
||||||
<Button Content="이 덩어리 반영" Command="{Binding AcceptBlockCommand}"
|
<Button Content="이 덩어리 반영" x:Name="AcceptBlockButton" Command="{Binding AcceptBlockCommand}"
|
||||||
MinHeight="30" Margin="2,4,2,0" FontSize="11"
|
MinHeight="30" Margin="2,4,2,0" FontSize="11"
|
||||||
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
|
ToolTip="차이가 이어지는 구간을 한 번에 반영합니다 (Alt+Shift+Right)"/>
|
||||||
|
|
||||||
|
<!-- 12차 G-2: 위 단추의 거울. 두 파일 비교(BothWays)에서만 뜬다 -
|
||||||
|
Git 모드의 왼쪽은 지난 커밋이라 쓸 파일이 없다. -->
|
||||||
|
<Button Content="이 덩어리 왼쪽으로" x:Name="AcceptBlockToBaseButton"
|
||||||
|
Command="{Binding AcceptBlockToBaseCommand}"
|
||||||
|
MinHeight="30" Margin="2,4,2,0" FontSize="11"
|
||||||
|
Visibility="{Binding BothWays, Converter={StaticResource BoolToVis}}"
|
||||||
|
ToolTip="차이가 이어지는 구간을 한 번에 왼쪽 파일로 보냅니다 (Alt+Shift+Left)"/>
|
||||||
|
|
||||||
<!-- 11차 F-2: 캐럿이 놓인 클래스 멤버를 통째로. 못 잡으면 꺼지고 아래 한 줄이 까닭을 말한다. -->
|
<!-- 11차 F-2: 캐럿이 놓인 클래스 멤버를 통째로. 못 잡으면 꺼지고 아래 한 줄이 까닭을 말한다. -->
|
||||||
<Button Content="이 메서드 반영" Command="{Binding AcceptMemberCommand}"
|
<Button Content="이 메서드 반영" Command="{Binding AcceptMemberCommand}"
|
||||||
MinHeight="30" Margin="2,4,2,0" FontSize="11"
|
MinHeight="30" Margin="2,4,2,0" FontSize="11"
|
||||||
|
|||||||
@ -1038,10 +1038,16 @@ namespace MergeHub.Views
|
|||||||
case ShortcutAction.CopyLeftToRight:
|
case ShortcutAction.CopyLeftToRight:
|
||||||
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand);
|
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptLocalCommand : vm.AcceptCommand);
|
||||||
|
|
||||||
|
// 2-Way 에서는 12차 G-2 부터 <이 줄을 왼쪽 파일로>다([<] 단추와 같은 일).
|
||||||
|
// 예전에는 '이 줄 되돌리기'였는데, 화살표가 가리키는 대로 왼쪽으로 보내는 자리로 넘겼다.
|
||||||
|
// 되돌리기는 Ctrl+Z 가 맡는다.
|
||||||
case ShortcutAction.CopyRightToLeft:
|
case ShortcutAction.CopyRightToLeft:
|
||||||
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptRemoteCommand : vm.RevertCommand);
|
return Run(vm.IsThreeWay ? (ICommand)vm.AcceptRemoteCommand : vm.TakeLineToBaseCommand,
|
||||||
|
vm.IsThreeWay ? null : vm.SelectedLine);
|
||||||
|
|
||||||
|
// 이 덩어리를 왼쪽 파일로(12차 G-2). Alt+Shift+→ 의 거울이다.
|
||||||
|
case ShortcutAction.AcceptBlockToBase: return Run(vm.AcceptBlockToBaseCommand);
|
||||||
|
|
||||||
// 되돌릴 머지 작업이 없으면 키를 먹지 않는다 - 그래야 편집기의 글자 되돌리기가 살아 있다.
|
|
||||||
case ShortcutAction.Undo: return Run(vm.UndoCommand);
|
case ShortcutAction.Undo: return Run(vm.UndoCommand);
|
||||||
case ShortcutAction.Redo: return Run(vm.RedoCommand);
|
case ShortcutAction.Redo: return Run(vm.RedoCommand);
|
||||||
|
|
||||||
@ -1110,10 +1116,13 @@ namespace MergeHub.Views
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>지금 못 하는 동작(예: 고칠 게 없는데 저장)이면 키를 먹지 않고 넘긴다.</summary>
|
/// <summary>지금 못 하는 동작(예: 고칠 게 없는데 저장)이면 키를 먹지 않고 넘긴다.</summary>
|
||||||
private static bool Run(ICommand command)
|
private static bool Run(ICommand command) => Run(command, null);
|
||||||
|
|
||||||
|
/// <summary>줄 하나를 받는 동작용(12차 G-2 - [<] 처럼 고른 줄에 대고 하는 것).</summary>
|
||||||
|
private static bool Run(ICommand command, object? parameter)
|
||||||
{
|
{
|
||||||
if (!command.CanExecute(null)) return false;
|
if (!command.CanExecute(parameter)) return false;
|
||||||
command.Execute(null);
|
command.Execute(parameter);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user